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,5 @@
import abc
class Accelerator(abc.ABC):
"""A utility that contains methods to accelerate training."""
@@ -0,0 +1,858 @@
import logging
import os
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Type,
TypeVar,
)
import ray
import ray._private.ray_constants as ray_constants
from ray._private.accelerators.amd_gpu import HIP_VISIBLE_DEVICES_ENV_VAR
from ray._private.accelerators.neuron import NEURON_RT_VISIBLE_CORES_ENV_VAR
from ray._private.accelerators.npu import ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
from ray._private.accelerators.nvidia_gpu import CUDA_VISIBLE_DEVICES_ENV_VAR
from ray._private.ray_constants import env_integer
from ray.exceptions import RayActorError
from ray.train._checkpoint import Checkpoint
from ray.train._internal.data_config import DataConfig
from ray.train._internal.session import (
TrialInfo,
_TrainingResult,
get_session,
init_session,
shutdown_session,
)
from ray.train._internal.storage import StorageContext
from ray.train._internal.utils import check_for_failure
from ray.train._internal.worker_group import WorkerGroup
from ray.train.backend import BackendConfig
from ray.train.constants import (
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV,
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
ENABLE_SHARE_HIP_VISIBLE_DEVICES_ENV,
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
ENABLE_SHARE_NPU_RT_VISIBLE_DEVICES_ENV,
RAY_TRAIN_ENABLE_STATE_TRACKING,
TRAIN_ENABLE_WORKER_SPREAD_ENV,
TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV,
)
from ray.util.placement_group import get_current_placement_group, remove_placement_group
if TYPE_CHECKING:
from ray.data import Dataset
T = TypeVar("T")
logger = logging.getLogger(__name__)
class TrainBackendError(Exception):
"""Errors with BackendExecutor that should not be exposed to user."""
class TrainingWorkerError(Exception):
"""Raised if a worker fails during training."""
@dataclass
class ResourceConfig:
"""
Resource configuration for resource_ids to share between workers.
Args:
resource_name: The name of the resource to configure
(Example: "neuron_cores" or "gpu").
resource_enable_sharing_env_var: The environment variable to
check if the resource should be shared.
share_resource_ids_env_var: The environment variable to configure for
sharing the resources with other workers.
"""
resource_name: str
resource_enable_sharing_env_var: str
share_resource_ids_env_var: str
class BackendExecutor:
"""Main execution class for training backends.
This class holds a worker group and is responsible for executing the
training function on the workers, and collecting intermediate results
from ``session.report()``.
Args:
backend_config: The configurations for this
specific backend.
trial_info: Information about the current Tune trial, if running under Tune.
num_workers: Number of workers to use for training.
resources_per_worker: Dictionary specifying the resources that will be
requested for each worker. Defaults to {"CPU": 1}.
max_retries: Number of retries when Ray actors fail.
Defaults to 3. Set to -1 for unlimited retries.
"""
def __init__(
self,
backend_config: BackendConfig,
# TODO(xwjiang): Legacy Ray Train trainer clean up!
trial_info: Optional[TrialInfo] = None,
num_workers: int = 1,
resources_per_worker: Optional[Dict[str, float]] = None,
max_retries: int = 3,
):
if resources_per_worker is None:
self._resources_per_worker = {"CPU": 1}
else:
self._resources_per_worker = resources_per_worker.copy()
self._backend_config = backend_config
self._backend = backend_config.backend_cls()
self._num_workers = num_workers
self._max_failures = max_retries
if self._max_failures < 0:
self._max_failures = float("inf")
self._num_failures = 0
self._last_failure = None
self._initialization_hook = None
self._placement_group = None
self._trial_info = trial_info
self.worker_group = InactiveWorkerGroup()
self.dataset_shards = None
self._resource_configs = [
ResourceConfig(
ray_constants.NEURON_CORES,
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
NEURON_RT_VISIBLE_CORES_ENV_VAR,
),
ResourceConfig(
ray_constants.NPU,
ENABLE_SHARE_NPU_RT_VISIBLE_DEVICES_ENV,
ASCEND_RT_VISIBLE_DEVICES_ENV_VAR,
),
# For AMD GPUs, they are using HIP_VISIBLE_DEVICES env var.
ResourceConfig(
ray_constants.GPU,
ENABLE_SHARE_HIP_VISIBLE_DEVICES_ENV,
HIP_VISIBLE_DEVICES_ENV_VAR,
),
]
# Record the initialization time of BackendExecutor, which is
# after trainer.fit() and before worker_group executes the training function.
self._start_time_ms = int(time.time() * 1000)
self.state_tracking_enabled = env_integer(RAY_TRAIN_ENABLE_STATE_TRACKING, 0)
def start(
self,
initialization_hook: Optional[Callable[[], None]] = None,
train_cls: Optional[Type] = None,
train_cls_args: Optional[Tuple] = None,
train_cls_kwargs: Optional[Dict] = None,
):
"""Starts the worker group."""
self._create_placement_group()
placement_group = self._placement_group or "default"
self.worker_group = WorkerGroup(
num_workers=self._num_workers,
resources_per_worker=self._resources_per_worker,
actor_cls=train_cls,
actor_cls_args=train_cls_args,
actor_cls_kwargs=train_cls_kwargs,
placement_group=placement_group,
)
# Hack to avoid OOMs.
# This is just a temporary solution for Train loading entire checkpoints
# into memory by ensuring that the rank 0 worker is on the same node as
# trainable, thus allowing for lazy checkpoint transfer to be used.
# See https://github.com/ray-project/ray/issues/33073
# for more context.
# TODO remove passing in trial_driver_ip.
trial_driver_node_id = (
self._trial_info.driver_node_id if self._trial_info else None
)
self.worker_group.sort_workers_by_node_id_and_gpu_id(trial_driver_node_id)
try:
if initialization_hook:
self._initialization_hook = initialization_hook
self.worker_group.execute(initialization_hook)
# Always propagate the driver's DataContext to each worker in the group.
from ray.data import DataContext
def _set_driver_dataset_context(ctx: DataContext):
DataContext._set_current(ctx)
self.worker_group.execute(
_set_driver_dataset_context,
DataContext.get_current(),
)
share_cuda_visible_devices_enabled = bool(
env_integer(
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
self._backend.share_cuda_visible_devices,
)
)
if (
self._resources_per_worker.get("GPU", 0) > 0
and share_cuda_visible_devices_enabled
):
self._share_cuda_visible_devices()
for resource_config in self._resource_configs:
if self._is_share_resources_enabled(
resource_config.resource_name,
resource_config.resource_enable_sharing_env_var,
):
self._share_resource_ids(
resource_config.resource_name,
resource_config.share_resource_ids_env_var,
)
self._backend.on_start(self.worker_group, self._backend_config)
except RayActorError as exc:
logger.exception(str(exc))
logger.warning(
"Failure occurred during startup. Restarting all workers and "
"attempting to startup again."
)
self._increment_failures()
self._restart()
if self.state_tracking_enabled:
from ray.train._internal.state import TrainRunStateManager
from ray.train._internal.state.state_actor import get_state_actor
self.state_manager = TrainRunStateManager(state_actor=get_state_actor())
def _create_placement_group(self):
"""Creates a placement group if it does not exist.
If a placement group is already detected (Tune) this will be a no-op.
By default the placement group will be created with PACK strategy.
This is optimized for colocating GPUs on a minimal number of nodes.
This behavior can be overridden to use the SPREAD strategy by defining
``TRAIN_ENABLE_WORKER_SPREAD_ENV``
If a placement group is created it will be stored as
self._placement_group.
"""
current_placement_group = get_current_placement_group()
worker = ray._private.worker.global_worker
should_capture_child_tasks_in_placement_group = (
worker.should_capture_child_tasks_in_placement_group
)
should_create_placement_group = (
current_placement_group is None
or not should_capture_child_tasks_in_placement_group
)
if should_create_placement_group:
bundles = [
self._resources_per_worker.copy() for _ in range(self._num_workers)
]
use_spread = bool(env_integer(TRAIN_ENABLE_WORKER_SPREAD_ENV, 0))
strategy = "SPREAD" if use_spread else "PACK"
placement_group = ray.util.placement_group(bundles, strategy=strategy)
logger.debug("Waiting for placement group to start.")
timeout = env_integer(TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV, 100)
ready, _ = ray.wait([placement_group.ready()], timeout=timeout)
if ready:
logger.debug("Placement group has started.")
else:
raise TimeoutError(
"Placement group creation timed out. Make sure your "
"cluster either has enough resources or use an "
"autoscaling cluster. If you are running on a cluster, "
"make sure you specify an address in `ray.init()`, for example, "
'`ray.init("auto")`. You can also increase the timeout by setting '
"the TRAIN_PLACEMENT_GROUP_TIMEOUT_S environment variable. "
"Current resources available: {}, resources requested by the "
"placement group: {}".format(
ray.available_resources(), placement_group.bundle_specs
)
)
self._placement_group = placement_group
def _share_cuda_visible_devices(self):
"""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"
"""
self._share_resource_ids(ray_constants.GPU, CUDA_VISIBLE_DEVICES_ENV_VAR)
def _share_resource_ids(self, resource: 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"
- Worker2: "0,1"
Args:
resource: The name of the resource/accelerator.
env_var: The name of the environment variable to set.
"""
node_ids_and_resource_ids = [
(
w.metadata.node_id,
w.metadata.resource_ids[resource],
)
for w in self.worker_group.workers
]
node_id_to_worker_id = defaultdict(set)
node_id_to_resource_ids = defaultdict(set)
for worker_id, (node_id, resource_ids) in enumerate(node_ids_and_resource_ids):
node_id_to_worker_id[node_id].add(worker_id)
node_id_to_resource_ids[node_id].update(resource_ids)
futures = []
for node_id, resource_ids in node_id_to_resource_ids.items():
resource_ids = sorted(resource_ids)
all_resource_ids = ",".join(resource_ids)
def set_resource_ids():
os.environ[env_var] = all_resource_ids
for worker_id in node_id_to_worker_id[node_id]:
futures.append(
self.worker_group.execute_single_async(worker_id, set_resource_ids)
)
ray.get(futures)
def _is_share_resources_enabled(self, resource_name: str, enable_sharing_env: str):
"""Whether to share resource IDs on all workers
based on enable_sharing_env.
This will return true if resources are requested and greater than 0.
Also, user can disable by configuring the `enable_sharing_env` to "0".
Args:
resource_name: The name of the resource/accelerator.
enable_sharing_env: The name of the environment variable
to check.
Returns:
True if resource sharing is enabled, False otherwise.
"""
has_resource_requested = self._resources_per_worker.get(resource_name, 0) > 0
return has_resource_requested and ray_constants.env_bool(
enable_sharing_env, True
)
def _create_rank_world_size_mappings(
self,
) -> Tuple[Dict[int, int], Dict[int, int], Dict[int, int]]:
"""Create rank and world size mappings for workers.
There are three maps returned:
- local_rank_map, which maps from worker world_rank to local_rank.
- local_world_size_map, which maps from world_rank to local_world_size
- node_rank_map, which maps from world rank to node rank
Example:
Worker 0: node 0
Worker 1: node 0
Worker 2: node 1
Worker 3: node 0
Worker 4: node 1
Workers 0, 1, 3 are on node 0.
Workers 2, 4 are on node 1.
Expected local_rank_map:
{
0 -> 0,
1 -> 1,
2 -> 0,
3 -> 2,
4 -> 1
}
Expected local_world_size_map:
{
0 -> 3,
1 -> 3,
2 -> 2,
3 -> 3,
4 -> 2
}
Expected node_rank_map:
{
0 -> 0,
1 -> 0,
2 -> 1,
3 -> 0,
4 -> 1
}
Returns:
A tuple of (local_rank_map, local_world_size_map, node_rank_map).
"""
local_rank_map = {} # map from world rank to local rank
local_world_size_map = {} # map from world rank to local world size
node_rank_map = {} # map from world rank to node rank
node_ids = {} # map from node id to node index
node_cnt = 0 # count the number of nodes
node_id_dict = defaultdict(
int
) # map from node id to the number of workers on it.
for world_rank in range(len(self.worker_group)):
worker = self.worker_group.workers[world_rank]
node_id = worker.metadata.node_id
local_rank_map[world_rank] = node_id_dict[node_id]
node_id_dict[node_id] += 1
if node_id not in node_ids:
node_ids[node_id] = node_cnt
node_cnt += 1
node_rank_map[world_rank] = node_ids[node_id]
for world_rank in range(len(self.worker_group)):
worker = self.worker_group.workers[world_rank]
node_id = worker.metadata.node_id
local_world_size_map[world_rank] = node_id_dict[node_id]
workers_info = "\n".join(
[
f"- (node_id={w.metadata.node_id}, ip={w.metadata.node_ip}, "
f"pid={w.metadata.pid}) world_rank={i}, "
f"local_rank={local_rank_map[i]}, node_rank={node_rank_map[i]}"
for i, w in enumerate(self.worker_group.workers)
]
)
logger.info(f"Started distributed worker processes: \n{workers_info}")
return local_rank_map, local_world_size_map, node_rank_map
def start_training(
self,
train_func: Callable[[], T],
datasets: Dict[str, "Dataset"],
metadata: Dict[str, Any],
data_config: DataConfig,
storage: StorageContext,
checkpoint: Optional[Checkpoint] = None,
) -> None:
"""Executes a training function on all workers in a separate thread.
``finish_training`` should be called after this.
Args:
train_func: The training function to run on each worker.
datasets: The base datasets.
metadata: User-supplied metadata dict propagated to checkpoints
created during training.
data_config: The config object for creating dataset shards for workers.
storage: The storage context, providing access to the experiment
directory and other persistent storage state.
checkpoint: The checkpoint data that
should be loaded onto each worker and accessed by the
training function via ``session.get_checkpoint()``. If this
is ``None`` then no checkpoint will be loaded.
"""
use_detailed_autofilled_metrics = env_integer(
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV, 0
)
# First initialize the session.
def initialize_session(
train_func,
world_rank,
local_rank,
node_rank,
local_world_size,
world_size,
trial_info,
checkpoint,
dataset_shard,
metadata,
storage,
):
try:
init_session(
training_func=train_func,
world_rank=world_rank,
local_rank=local_rank,
node_rank=node_rank,
local_world_size=local_world_size,
world_size=world_size,
trial_info=trial_info,
dataset_shard=dataset_shard,
metadata=metadata,
checkpoint=checkpoint,
detailed_autofilled_metrics=use_detailed_autofilled_metrics,
storage=storage,
)
except ValueError:
raise TrainBackendError(
"Attempting to start training but a "
"previous training run is still ongoing. "
"You must call `finish_training` before "
"calling `start_training` again."
)
if self.dataset_shards is None:
actors = [worker.actor for worker in self.worker_group.workers]
node_ids = [worker.metadata.node_id for worker in self.worker_group.workers]
self.dataset_shards = data_config.configure(
datasets,
world_size=len(self.worker_group),
worker_handles=actors,
worker_node_ids=node_ids,
)
(
local_rank_map,
local_world_size_map,
node_rank_map,
) = self._create_rank_world_size_mappings()
futures = []
for index in range(len(self.worker_group)):
futures.append(
self.worker_group.execute_single_async(
index,
initialize_session,
world_rank=index,
local_rank=local_rank_map[index],
node_rank=node_rank_map[index],
local_world_size=local_world_size_map[index],
world_size=len(self.worker_group),
trial_info=self._trial_info,
train_func=train_func,
dataset_shard=self.dataset_shards[index],
metadata=metadata,
checkpoint=checkpoint,
storage=storage,
)
)
self._backend.on_training_start(self.worker_group, self._backend_config)
self.get_with_failure_handling(futures)
# Register Train Run before training starts
if self.state_tracking_enabled:
from ray.train._internal.state.schema import RunStatusEnum
core_context = ray.runtime_context.get_runtime_context()
self.state_manager.register_train_run(
run_id=self._trial_info.run_id,
run_name=self._trial_info.experiment_name,
job_id=core_context.get_job_id(),
controller_actor_id=core_context.get_actor_id(),
datasets=datasets,
worker_group=self.worker_group,
start_time_ms=self._start_time_ms,
run_status=RunStatusEnum.RUNNING,
resources=[self._resources_per_worker] * self._num_workers,
)
# Run the training function asynchronously in its own thread.
def train_async():
session = get_session()
session.start()
self.worker_group.execute_async(train_async)
def get_next_results(self) -> Optional[List[_TrainingResult]]:
"""Fetches the next ``_TrainingResult`` from each worker.
Each ``_TrainingResult`` is expected to correspond to the same step from
each worker (e.g. the same call to ``train.report()``).
Returns:
A list of ``_TrainingResult``s or ``None`` if there are no more results
since the training function has exited on all workers.
"""
def get_next():
session = _get_session("get_next_results")
try:
result = session.get_next()
except RuntimeError:
# Training thread has not been started yet.
raise TrainBackendError(
"`get_next_results` has been called "
"before `start_training`. Please call "
"`start_training` before "
"`get_next_results`."
)
return result
# Get next result from each worker.
futures = self.worker_group.execute_async(get_next)
results = self.get_with_failure_handling(futures)
# Check if any worker returned None.
if any(r is None for r in results):
# Either all workers have results or none of them do.
if not all(r is None for r in results):
raise RuntimeError(
"Some workers returned results while "
"others didn't. Make sure that "
"`session.report()` are called the "
"same number of times on all workers."
)
else:
# Return None if all results are None.
return None
return results
def pause_reporting(self):
"""Disable workers from enqueuing results from ``session.report()``.
Note: Already reported results may still be enqueued at this point,
and should be handled appropriately.
"""
def pause_session_reporting():
session = _get_session("pause_reporting")
return session.pause_reporting()
futures = self.worker_group.execute_async(pause_session_reporting)
self.get_with_failure_handling(futures)
def finish_training(self):
"""Finish training and return final results. Propagate any exceptions.
Blocks until training is finished on all workers.
Assumes `start_training` has already been called.
Returns:
A list of return values from calling ``train_func`` on each worker.
Each item corresponds to the return value from a single worker.
"""
def end_training():
session = _get_session("finish_training")
try:
# session.finish raises any Exceptions from training.
output = session.finish()
finally:
# Shutdown session even if session.finish() raises an
# Exception.
shutdown_session()
return output
futures = self.worker_group.execute_async(end_training)
results = self.get_with_failure_handling(futures)
return results
def report_final_run_status(
self,
errored: bool = False,
failed_rank: Optional[int] = None,
stack_trace: Optional[str] = None,
):
"""Report the final train run status, error, and end time to TrainStateActor."""
if self.state_tracking_enabled:
from ray.train._internal.state.schema import (
MAX_ERROR_STACK_TRACE_LENGTH,
RunStatusEnum,
)
if errored:
run_status = RunStatusEnum.ERRORED
status_detail = ""
if failed_rank is not None:
status_detail += f"Rank {failed_rank} worker raised an error. \n"
if stack_trace is not None:
# Keep only the last part of the stack trace if it's too long.
status_detail += stack_trace[-MAX_ERROR_STACK_TRACE_LENGTH:]
else:
run_status = RunStatusEnum.FINISHED
status_detail = ""
self.state_manager.end_train_run(
run_id=self._trial_info.run_id,
run_status=run_status,
status_detail=status_detail,
end_time_ms=int(time.time() * 1000),
)
def get_with_failure_handling(self, remote_values: List[ray.ObjectRef]):
"""Gets the remote values while handling for worker failures.
This method should be called instead of ``ray.get()`` directly in
order to handle worker failures.
If a worker failure is identified, backend specific failure handling
is executed and a ``TrainingWorkerError`` is raised.
Args:
remote_values: List of object refs representing functions
that may fail in the middle of execution. For example, running
a Train training loop in multiple parallel actor calls.
Returns:
The resolved objects represented by the passed in ObjectRefs.
"""
success, exception = check_for_failure(remote_values)
if success:
return ray.get(remote_values)
else:
self._last_failure = exception
self._increment_failures()
logger.warning(
"Failure identified during training. Restarting all workers and "
"continuing training from latest checkpoint."
)
self._restart()
raise TrainingWorkerError
def shutdown(self, graceful_termination: bool = True):
"""Shuts down the workers in the worker group.
Args:
graceful_termination: If set to True, attempt to clean up the backend
before terminating the Ray actors.
"""
if graceful_termination:
try:
self._backend.on_shutdown(self.worker_group, self._backend_config)
except RayActorError:
logger.warning(
"Graceful shutdown of backend failed. This is "
"expected if one of the workers has crashed."
)
if graceful_termination:
self.worker_group.shutdown()
else:
self.worker_group.shutdown(patience_s=0)
self.worker_group = InactiveWorkerGroup()
if self._placement_group:
remove_placement_group(self._placement_group)
self._placement_group = None
self.dataset_shards = None
def is_started(self):
return not isinstance(self.worker_group, InactiveWorkerGroup)
def _restart(self):
self.worker_group.shutdown()
if self._initialization_hook is not None:
initialization_hook = self._initialization_hook
else:
initialization_hook = None
if self._placement_group:
remove_placement_group(self._placement_group)
self._placement_group = None
self.start(initialization_hook=initialization_hook)
def _increment_failures(self):
self._num_failures += 1
if self._num_failures >= self._max_failures:
failure = self._last_failure
self._last_failure = None
if self._max_failures > 0:
exc = RuntimeError(
f"Training has failed after {self._num_failures} attempts."
)
raise exc.with_traceback(None) from failure
else:
raise failure
def get_worker_group(self):
return self.worker_group
def _get_num_failures(self):
return self._num_failures
class InactiveWorkerGroupError(Exception):
"""Raised when underlying worker group is inactive."""
class InactiveWorkerGroup:
# TODO: fix inheritence. perhaps create WorkerGroupInterface.
# Need to define getstate and setstate so that getattr does not screwup
# pickling. See https://stackoverflow.com/a/50888571/11249691
def __getstate__(self):
return vars(self)
def __setstate__(self, state):
vars(self).update(state)
def __getattr__(self, name):
raise InactiveWorkerGroupError()
def __len__(self):
raise InactiveWorkerGroupError()
def _get_session(method_name: str):
# Get the session for this worker.
session = get_session()
if not session:
# Session is not initialized yet.
raise TrainBackendError(
f"`{method_name}` has been called "
"before `start_training`. Please call "
"`start_training` before "
f"`{method_name}`."
)
return session
@@ -0,0 +1,105 @@
"""Abstract base class for WorkerGroup implementations.
This module defines the common base class that both V1 and V2 WorkerGroup
implementations should inherit from to ensure backend compatibility.
"""
import abc
from typing import Callable, List, TypeVar
from ray.types import ObjectRef
from ray.util.annotations import DeveloperAPI
T = TypeVar("T")
@DeveloperAPI
class BaseWorkerGroup(abc.ABC):
"""Abstract base class for WorkerGroup implementations.
This base class defines the minimal set of methods that backend classes
expect from WorkerGroup implementations. Both V1 and V2 WorkerGroup
classes should inherit from this base class to ensure compatibility with
all backend configurations.
The interface focuses on the core operations that backends need:
- Executing functions on workers
- Getting worker count and resource allocation
"""
@abc.abstractmethod
def execute(self, func: Callable[..., T], *args, **kwargs) -> List[T]:
"""Execute a function on all workers synchronously.
Args:
func: The function to execute on each worker.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
A list of results from each worker, in worker rank order.
"""
pass
@abc.abstractmethod
def execute_async(self, func: Callable[..., T], *args, **kwargs) -> List[ObjectRef]:
"""Execute a function on all workers asynchronously.
Args:
func: The function to execute on each worker.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
A list of ObjectRef results from each worker, in worker rank order.
"""
pass
@abc.abstractmethod
def execute_single(
self, worker_index: int, func: Callable[..., T], *args, **kwargs
) -> T:
"""Execute a function on a single worker synchronously.
Args:
worker_index: The index of the worker to execute on.
func: The function to execute.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result from the specified worker.
"""
pass
@abc.abstractmethod
def execute_single_async(
self, worker_index: int, func: Callable[..., T], *args, **kwargs
) -> ObjectRef:
"""Execute a function on a single worker asynchronously.
Args:
worker_index: The index of the worker to execute on.
func: The function to execute.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
An ObjectRef to the result from the specified worker.
"""
pass
@abc.abstractmethod
def __len__(self) -> int:
"""Return the number of workers in the group."""
pass
@abc.abstractmethod
def get_resources_per_worker(self) -> dict:
"""Get the resources allocated per worker.
Returns:
A dictionary mapping resource names to quantities per worker.
Common keys include "CPU", "GPU", "memory".
"""
pass
@@ -0,0 +1,206 @@
import logging
import numbers
from typing import Any, Callable, Dict, List, Optional, Tuple
from ray._private import ray_constants
from ray._private.dict import flatten_dict
from ray.air._internal.util import is_nan
from ray.air.config import MAX
from ray.train import Checkpoint, CheckpointConfig
from ray.train._internal.session import _TrainingResult
from ray.train._internal.storage import _delete_fs_path
from ray.train.constants import TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE
logger = logging.getLogger(__name__)
def _insert_into_sorted_list(
list: List[_TrainingResult],
item: _TrainingResult,
key: Callable[[_TrainingResult], Any],
checkpoint_to_report_index: Optional[Dict[Checkpoint, int]] = None,
):
"""Insert an item into a sorted list with a custom key function.
Args:
list: The list to insert the item into.
item: The item to insert.
key: The key function to use to sort the list.
checkpoint_to_report_index: A dictionary mapping checkpoints to report indices.
Used to break ties when scores are equal.
"""
checkpoint_to_report_index = checkpoint_to_report_index or {}
# TODO: optimize this with sortedlist, batching, etc
i = 0
while i < len(list):
# When scores are equal, later checkpoints are later in the list.
list_item_key, item_key = key(list[i]), key(item)
if list_item_key > item_key or (
list_item_key == item_key
and checkpoint_to_report_index.get(list[i].checkpoint, 0)
> checkpoint_to_report_index.get(item.checkpoint, 0)
):
break
i += 1
list.insert(i, item)
class _CheckpointManager:
"""Checkpoint manager that handles checkpoint book-keeping for a trial.
The main purpose of this abstraction is to keep the top K checkpoints based on
recency/a user-provided metric.
NOTE: This class interacts with `_TrainingResult` objects, which are
(checkpoint, metrics) pairs. This is to order checkpoints by metrics.
Args:
checkpoint_config: Defines how many and which checkpoints to keep.
"""
def __init__(self, checkpoint_config: Optional[CheckpointConfig]):
self._checkpoint_config = checkpoint_config or CheckpointConfig()
# List of checkpoints ordered by ascending score.
self._checkpoint_results: List[_TrainingResult] = []
# The latest registered checkpoint.
# This should never be immediately deleted upon registration,
# even if it's not in the top K checkpoints, based on score.
self._latest_checkpoint_result: Optional[_TrainingResult] = None
if (
self._checkpoint_config.num_to_keep is not None
and self._checkpoint_config.num_to_keep <= 0
):
raise ValueError(
f"`num_to_keep` must >= 1, got: "
f"{self._checkpoint_config.num_to_keep}"
)
@property
def checkpoint_config(self):
return self._checkpoint_config
def register_checkpoint(self, checkpoint_result: _TrainingResult):
"""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:
checkpoint_result: Tracked training result containing the checkpoint
and associated metrics to add to bookkeeping.
"""
self._latest_checkpoint_result = checkpoint_result
score_attr = self._checkpoint_config.checkpoint_score_attribute
if ray_constants.env_bool(TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE, False):
metrics = (
{score_attr: checkpoint_result.metrics[score_attr]}
if score_attr in checkpoint_result.metrics
else {}
)
checkpoint_result = _TrainingResult(
checkpoint=checkpoint_result.checkpoint,
metrics=metrics,
)
if score_attr is not None and score_attr in checkpoint_result.metrics:
# 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,
)
else:
# If no metric is provided, just append (ordering by time of registration).
self._checkpoint_results.append(checkpoint_result)
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.
results_to_delete = worst_results - {self._latest_checkpoint_result}
# 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:
checkpoint = checkpoint_result.checkpoint
logger.debug("Deleting checkpoint: %s", checkpoint)
_delete_fs_path(fs=checkpoint.filesystem, fs_path=checkpoint.path)
def _get_checkpoint_score(
self, checkpoint: _TrainingResult
) -> Tuple[bool, numbers.Number]:
"""Get the score for a checkpoint, according to checkpoint config.
If `mode="min"`, the metric is negated so that the lowest score is
treated as the best.
Args:
checkpoint: The training result whose metrics should be scored.
Returns:
Tuple: A tuple of (not_is_nan: bool, score: numbers.Number).
This score orders: nan values < float("-inf") < valid numeric metrics
"""
checkpoint_score_attribute = self._checkpoint_config.checkpoint_score_attribute
if checkpoint_score_attribute:
flat_metrics = flatten_dict(checkpoint.metrics)
try:
checkpoint_result = flat_metrics[checkpoint_score_attribute]
except KeyError:
valid_keys = list(flat_metrics.keys())
logger.error(
f"Result dict has no key: {checkpoint_score_attribute}. "
f"checkpoint_score_attr must be set to a key in the "
f"result dict. Valid keys are: {valid_keys}"
)
checkpoint_result = float("-inf")
else:
checkpoint_result = float("-inf")
checkpoint_score_order = self._checkpoint_config.checkpoint_score_order
order_factor = 1.0 if checkpoint_score_order == MAX else -1.0
checkpoint_score = order_factor * checkpoint_result
if not isinstance(checkpoint_score, numbers.Number):
raise ValueError(
f"Unable to persist checkpoint for "
f"checkpoint_score_attribute: "
f"{checkpoint_score_attribute} with value "
f"{checkpoint_score}. "
f"This attribute must be numerical."
)
return (
(not is_nan(checkpoint_score), checkpoint_score)
if not is_nan(checkpoint_score)
else (False, float("-inf"))
)
@property
def best_checkpoint_result(self) -> Optional[_TrainingResult]:
return self._checkpoint_results[-1] if self._checkpoint_results else None
@property
def latest_checkpoint_result(self) -> Optional[_TrainingResult]:
return self._latest_checkpoint_result
@property
def best_checkpoint_results(self) -> List[_TrainingResult]:
if self._checkpoint_config.num_to_keep is None:
return self._checkpoint_results
return self._checkpoint_results[-self._checkpoint_config.num_to_keep :]
+178
View File
@@ -0,0 +1,178 @@
import copy
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union
from ray.actor import ActorHandle
from ray.util.annotations import DeveloperAPI, PublicAPI
if TYPE_CHECKING:
from ray.data import DataIterator, Dataset, ExecutionOptions, NodeIdStr
@PublicAPI(stability="stable")
class DataConfig:
"""Class responsible for configuring Train dataset preprocessing.
For advanced use cases, this class can be subclassed and the `configure()` method
overridden for custom data preprocessing.
"""
def __init__(
self,
datasets_to_split: Union[Literal["all"], List[str]] = "all",
execution_options: Optional[
Union["ExecutionOptions", Dict[str, "ExecutionOptions"]]
] = None,
enable_shard_locality: bool = True,
):
"""Construct a DataConfig.
Args:
datasets_to_split: Specifies which datasets should be split among workers.
Can be set to "all" or a list of dataset names. Defaults to "all",
i.e. split all datasets.
execution_options: The execution options to pass to Ray Data. Can be either:
1. A single ExecutionOptions object that is applied to all datasets.
2. A dict mapping dataset names to ExecutionOptions. If a dataset name
is not in the dict, it defaults to ``DataConfig.default_ingest_options()``.
By default, the options are optimized for data ingest. When overriding,
base your options off ``DataConfig.default_ingest_options()``.
enable_shard_locality: If true, dataset sharding across Train workers will
consider locality to minimize cross-node data transfer. Enabled by default.
"""
from ray.data import ExecutionOptions
if isinstance(datasets_to_split, list) or datasets_to_split == "all":
self._datasets_to_split = datasets_to_split
else:
raise TypeError(
"`datasets_to_split` should be a 'all' or a list of strings of "
"dataset names. Received "
f"{type(datasets_to_split).__name__} with value {datasets_to_split}."
)
default_execution_options = DataConfig.default_ingest_options()
if isinstance(execution_options, ExecutionOptions):
default_execution_options = execution_options
# If None, all datasets will use the default ingest options.
self._execution_options: Dict[str, "ExecutionOptions"] = defaultdict(
lambda: copy.deepcopy(default_execution_options)
)
if isinstance(execution_options, dict):
self._execution_options.update(execution_options)
self._enable_shard_locality = enable_shard_locality
self._num_train_cpus = 0.0
self._num_train_gpus = 0.0
def set_train_total_resources(self, num_train_cpus: float, num_train_gpus: float):
"""Set the total number of CPUs and GPUs used by training.
If CPU or GPU resource limits are not set, they will be set to the
total cluster resources minus the resources used by training.
"""
# TODO: We may also include other resources besides CPU and GPU.
self._num_train_cpus = num_train_cpus
self._num_train_gpus = num_train_gpus
def _get_execution_options(self, dataset_name: str) -> "ExecutionOptions":
"""Return a copy of the configured execution options for a given dataset name."""
return copy.deepcopy(self._execution_options[dataset_name])
@DeveloperAPI
def configure(
self,
datasets: Dict[str, "Dataset"],
world_size: int,
worker_handles: Optional[List[ActorHandle]],
worker_node_ids: Optional[List["NodeIdStr"]],
**kwargs,
) -> List[Dict[str, "DataIterator"]]:
"""Configure how Train datasets should be assigned to workers.
Args:
datasets: The datasets dict passed to Train by the user.
world_size: The number of Train workers in total.
worker_handles: The actor handles of the Train workers.
worker_node_ids: The node ids of the Train workers.
**kwargs: Forwards compatibility placeholder.
Returns:
A list of dataset splits for each worker. The size of the list must be
equal to `world_size`. Each element of the list contains the assigned
`DataIterator` instances by name for the worker.
"""
from ray.data._internal.execution.interfaces.execution_options import (
ExecutionResources,
)
output = [{} for _ in range(world_size)]
for dataset_name, dataset in datasets.items():
if dataset.name is None:
dataset.set_name(dataset_name)
if self._datasets_to_split == "all":
datasets_to_split = set(datasets.keys())
else:
datasets_to_split = set(self._datasets_to_split)
locality_hints = worker_node_ids if self._enable_shard_locality else None
for name, ds in datasets.items():
execution_options = self._get_execution_options(name)
if execution_options.is_resource_limits_default():
if not self._scaling_policy_reserves_train_resources():
execution_options.exclude_resources = (
execution_options.exclude_resources.add(
ExecutionResources(
cpu=self._num_train_cpus, gpu=self._num_train_gpus
)
)
)
ds = ds.copy(ds)
ds.context.execution_options = execution_options
if name in datasets_to_split:
for i, split in enumerate(
ds.streaming_split(
world_size, equal=True, locality_hints=locality_hints
)
):
output[i][name] = split
else:
for i in range(world_size):
output[i][name] = ds.iterator()
return output
@classmethod
def _scaling_policy_reserves_train_resources(cls) -> bool:
"""True iff Ray Train V2's ScalingPolicy will register training resources
with the AutoscalingCoordinator for this run.
"""
from ray.train.v2._internal.constants import is_v2_enabled
return is_v2_enabled()
@staticmethod
def default_ingest_options() -> "ExecutionOptions":
"""The default Ray Data options used for data ingest.
By default, configurations are carried over from what is already set
in DataContext.
"""
from ray.data import ExecutionOptions
from ray.data.context import DataContext
ctx = DataContext.get_current()
return ExecutionOptions(
resource_limits=ctx.execution_options.resource_limits,
exclude_resources=ctx.execution_options.exclude_resources,
preserve_order=ctx.execution_options.preserve_order,
verbose_progress=ctx.execution_options.verbose_progress,
)
@@ -0,0 +1,57 @@
from typing import TYPE_CHECKING, Optional
import ray.cloudpickle as ray_pickle
from ray._common.utils import binary_to_hex, hex_to_binary
from ray.train._checkpoint import Checkpoint
if TYPE_CHECKING:
from ray.data.preprocessor import Preprocessor
PREPROCESSOR_KEY = "preprocessor_pkl"
class FrameworkCheckpoint(Checkpoint):
"""A checkpoint to preserve the functionality of legacy
framework-specific checkpoints.
Example:
>>> import tempfile
>>> from ray.data.preprocessor import Preprocessor
>>> checkpoint = FrameworkCheckpoint(tempfile.mkdtemp())
>>> checkpoint.get_preprocessor() is None
True
>>> preprocessor = Preprocessor()
>>> preprocessor._attr = 1234
>>> checkpoint.set_preprocessor(preprocessor)
>>> checkpoint.get_preprocessor()._attr
1234
"""
def get_preprocessor(self) -> Optional["Preprocessor"]:
"""Return the preprocessor stored in the checkpoint.
.. warning::
The checkpoint path must point to a **trusted** source.
The preprocessor is stored as a pickle blob inside the checkpoint
metadata. Loading a checkpoint from an untrusted path (shared
storage, downloaded artifact, checkpoint produced by a different
party) is equivalent to executing arbitrary Python code. Never
call this method on a checkpoint you do not fully control.
Returns:
The preprocessor stored in the checkpoint, or ``None`` if no
preprocessor was stored.
"""
metadata = self.get_metadata()
preprocessor_bytes = metadata.get(PREPROCESSOR_KEY)
if preprocessor_bytes is None:
return None
return ray_pickle.loads(hex_to_binary(preprocessor_bytes))
def set_preprocessor(self, preprocessor: "Preprocessor"):
"""Store a preprocessor with the checkpoint."""
self.update_metadata(
{PREPROCESSOR_KEY: binary_to_hex(ray_pickle.dumps(preprocessor))}
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
from ray.train._internal.state.state_manager import TrainRunStateManager
try:
import pydantic # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"pydantic isn't installed."
"To install pydantic, please run 'pip install pydantic'"
)
__all__ = [
"TrainRunStateManager",
]
+105
View File
@@ -0,0 +1,105 @@
from typing import Optional
from ray.core.generated.export_train_state_pb2 import (
ExportTrainRunAttemptEventData as ProtoTrainRunAttempt,
ExportTrainRunEventData as ProtoTrainRun,
)
from ray.train._internal.state.schema import (
ActorStatusEnum,
RunStatusEnum,
TrainRunInfo,
TrainWorkerInfo,
)
TRAIN_SCHEMA_VERSION = 1
RAY_TRAIN_VERSION = 1
# Status mapping dictionaries
_ACTOR_STATUS_MAP = {
ActorStatusEnum.ALIVE: ProtoTrainRunAttempt.ActorStatus.ALIVE,
ActorStatusEnum.DEAD: ProtoTrainRunAttempt.ActorStatus.DEAD,
}
_RUN_ATTEMPT_STATUS_MAP = {
RunStatusEnum.STARTED: ProtoTrainRunAttempt.RunAttemptStatus.PENDING,
RunStatusEnum.RUNNING: ProtoTrainRunAttempt.RunAttemptStatus.RUNNING,
RunStatusEnum.FINISHED: ProtoTrainRunAttempt.RunAttemptStatus.FINISHED,
RunStatusEnum.ERRORED: ProtoTrainRunAttempt.RunAttemptStatus.ERRORED,
RunStatusEnum.ABORTED: ProtoTrainRunAttempt.RunAttemptStatus.ABORTED,
}
_RUN_STATUS_MAP = {
RunStatusEnum.STARTED: ProtoTrainRun.RunStatus.INITIALIZING,
RunStatusEnum.RUNNING: ProtoTrainRun.RunStatus.RUNNING,
RunStatusEnum.FINISHED: ProtoTrainRun.RunStatus.FINISHED,
RunStatusEnum.ERRORED: ProtoTrainRun.RunStatus.ERRORED,
RunStatusEnum.ABORTED: ProtoTrainRun.RunStatus.ABORTED,
}
def _ms_to_ns(ms: Optional[int]) -> Optional[int]:
if ms is None:
return None
return ms * 1000000
# Helper conversion functions
def _to_proto_resources(resources: dict) -> ProtoTrainRunAttempt.TrainResources:
"""Convert resources dictionary to protobuf TrainResources."""
return ProtoTrainRunAttempt.TrainResources(resources=resources)
def _to_proto_worker(worker: TrainWorkerInfo) -> ProtoTrainRunAttempt.TrainWorker:
"""Convert TrainWorker to protobuf format."""
proto_worker = 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=_ACTOR_STATUS_MAP[worker.status],
resources=_to_proto_resources(worker.resources),
)
return proto_worker
# Main conversion functions
def train_run_info_to_proto_run(run_info: TrainRunInfo) -> ProtoTrainRun:
"""Convert TrainRunInfo to TrainRun protobuf format."""
proto_run = ProtoTrainRun(
schema_version=TRAIN_SCHEMA_VERSION,
ray_train_version=RAY_TRAIN_VERSION,
id=run_info.id,
name=run_info.name,
job_id=bytes.fromhex(run_info.job_id),
controller_actor_id=bytes.fromhex(run_info.controller_actor_id),
status=_RUN_STATUS_MAP[run_info.run_status],
status_detail=run_info.status_detail,
start_time_ns=_ms_to_ns(run_info.start_time_ms),
end_time_ns=_ms_to_ns(run_info.end_time_ms),
)
return proto_run
def train_run_info_to_proto_attempt(run_info: TrainRunInfo) -> ProtoTrainRunAttempt:
"""Convert TrainRunInfo to TrainRunAttempt protobuf format."""
proto_attempt = ProtoTrainRunAttempt(
schema_version=TRAIN_SCHEMA_VERSION,
ray_train_version=RAY_TRAIN_VERSION,
run_id=run_info.id,
attempt_id=run_info.id, # Same as run_id
status=_RUN_ATTEMPT_STATUS_MAP[run_info.run_status],
status_detail=run_info.status_detail,
start_time_ns=_ms_to_ns(run_info.start_time_ms),
end_time_ns=_ms_to_ns(run_info.end_time_ms),
resources=[_to_proto_resources(r) for r in run_info.resources],
workers=[_to_proto_worker(worker) for worker in run_info.workers],
)
return proto_attempt
+165
View File
@@ -0,0 +1,165 @@
from enum import Enum
from typing import Dict, List, Optional
from ray._common.pydantic_compat import BaseModel, Field
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.util.annotations import DeveloperAPI
MAX_ERROR_STACK_TRACE_LENGTH = 50000
@DeveloperAPI
class RunStatusEnum(str, Enum):
"""Enumeration for the status of a train run."""
# (Deprecated) Replaced by RUNNING.
# The train run has started
STARTED = "STARTED"
# The train run is running
RUNNING = "RUNNING"
# The train run was terminated as expected
FINISHED = "FINISHED"
# The train run was terminated early due to errors in the training function
ERRORED = "ERRORED"
# The train run was terminated early due to system errors or controller errors
ABORTED = "ABORTED"
@DeveloperAPI
class ActorStatusEnum(str, Enum):
DEAD = "DEAD"
ALIVE = "ALIVE"
@DeveloperAPI
class TrainWorkerInfo(BaseModel):
"""Metadata of a Ray Train worker."""
actor_id: str = Field(description="Actor ID of the worker.")
world_rank: int = Field(description="World rank of the worker.")
local_rank: int = Field(description="Local rank of the worker.")
node_rank: int = Field(description="Node rank of the worker.")
node_id: str = Field(description="ID of the node that the worker is running on.")
node_ip: str = Field(
description="IP address of the node that the worker is running on."
)
pid: int = Field(description="Process ID of the worker.")
gpu_ids: List[int] = Field(
description="A list of GPU ids allocated to that worker."
)
status: ActorStatusEnum = Field(
description="The status of the train worker actor. It can be ALIVE or DEAD."
)
resources: Dict[str, float] = Field(
description="The resources allocated to the worker."
)
@DeveloperAPI
class MemoryInfo(BaseModel):
rss: int
vms: int
pfaults: Optional[int] = None
pageins: Optional[int] = None
@DeveloperAPI
class ProcessStats(BaseModel):
cpuPercent: float
# total memory, free memory, memory used ratio
mem: Optional[List[int]] = None
memoryInfo: MemoryInfo
class ProcessGPUUsage(BaseModel):
# This gpu usage stats from a process
pid: int
gpuMemoryUsage: int
@DeveloperAPI
class GPUStats(BaseModel):
uuid: str
index: int
name: str
utilizationGpu: Optional[float] = None
memoryUsed: float
memoryTotal: float
processInfo: ProcessGPUUsage
@DeveloperAPI
class TrainWorkerInfoWithDetails(TrainWorkerInfo):
"""Metadata of a Ray Train worker."""
processStats: Optional[ProcessStats] = Field(
None, description="Process stats of the worker."
)
gpus: List[GPUStats] = Field(
default_factory=list,
description=(
"GPU stats of the worker. "
"Only returns GPUs that are attached to the worker process."
),
)
@DeveloperAPI
class TrainDatasetInfo(BaseModel):
name: str = Field(
description="The key of the dataset dict specified in Ray Train Trainer."
)
dataset_uuid: str = Field(description="The uuid of the dataset.")
dataset_name: Optional[str] = Field(None, description="The name of the dataset.")
@DeveloperAPI
class TrainRunInfo(BaseModel):
"""Metadata for a Ray Train run and information about its workers."""
name: str = Field(description="The name of the Train run.")
id: str = Field(description="The unique identifier for each Train run.")
job_id: str = Field(description="The Ray Job ID.")
controller_actor_id: str = Field(description="Actor Id of the Train controller.")
workers: List[TrainWorkerInfo] = Field(
description="A List of Train workers sorted by global ranks."
)
datasets: List[TrainDatasetInfo] = Field(
description="A List of dataset info for this Train run."
)
run_status: RunStatusEnum = Field(
description="The current status of the train run. It can be one of the "
"following: RUNNING, FINISHED, ERRORED, or ABORTED."
)
status_detail: str = Field(
description="Detailed information about the current run status, "
"such as error messages."
)
start_time_ms: int = Field(
description="The UNIX timestamp of the start time of this Train run."
)
end_time_ms: Optional[int] = Field(
None,
description="The UNIX timestamp of the end time of this Train run. "
"If null, the Train run has not ended yet.",
)
resources: List[Dict[str, float]] = Field(
description="The resources allocated to the worker."
)
@DeveloperAPI
class TrainRunInfoWithDetails(TrainRunInfo):
"""Metadata for a Ray Train run and information about its workers."""
workers: List[TrainWorkerInfoWithDetails] = Field(
description="A List of Train workers sorted by global ranks."
)
job_details: Optional[JobDetails] = Field(
None, description="Details of the job that started this Train run."
)
@DeveloperAPI
class TrainRunsResponse(BaseModel):
train_runs: List[TrainRunInfoWithDetails]
@@ -0,0 +1,152 @@
import logging
import os
import threading
from typing import Dict, Optional
import ray
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._internal.state.schema import TrainRunInfo
logger = logging.getLogger(__name__)
@ray.remote(num_cpus=0)
class TrainStateActor:
def __init__(self):
self._run_infos: Dict[str, TrainRunInfo] = {}
(
self._export_logger,
self._is_train_run_export_api_enabled,
self._is_train_run_attempt_export_api_enabled,
) = self._init_export_logger()
def register_train_run(self, run_info: TrainRunInfo) -> None:
# Register a new train run.
self._run_infos[run_info.id] = run_info
self._maybe_export_train_run(run_info)
self._maybe_export_train_run_attempt(run_info)
def get_train_run(self, run_id: str) -> Optional[TrainRunInfo]:
# Retrieve a registered run with its id
return self._run_infos.get(run_id, None)
def get_all_train_runs(self) -> Dict[str, TrainRunInfo]:
# Retrieve all registered train runs
return self._run_infos
# ============================
# 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_info: TrainRunInfo) -> None:
if not self._is_train_run_export_api_enabled:
return
from ray.train._internal.state.export import train_run_info_to_proto_run
run_proto = train_run_info_to_proto_run(run_info)
self._export_logger.send_event(run_proto)
def _maybe_export_train_run_attempt(self, run_info: TrainRunInfo) -> None:
if not self._is_train_run_attempt_export_api_enabled:
return
from ray.train._internal.state.export import train_run_info_to_proto_attempt
run_attempt_proto = train_run_info_to_proto_attempt(run_info)
self._export_logger.send_event(run_attempt_proto)
TRAIN_STATE_ACTOR_NAME = "train_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 a `TrainStateActor` on the head node."""
with _state_actor_lock:
state_actor = TrainStateActor.options(
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",
).remote()
# Ensure the state actor is ready
ray.get(state_actor.__ray_ready__.remote())
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,132 @@
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List
import ray
from ray.train._internal.state.schema import (
ActorStatusEnum,
RunStatusEnum,
TrainDatasetInfo,
TrainRunInfo,
TrainWorkerInfo,
)
from ray.train._internal.utils import check_for_failure
from ray.train._internal.worker_group import WorkerGroup
if TYPE_CHECKING:
from ray.data import Dataset
logger = logging.getLogger(__name__)
class TrainRunStateManager:
"""A class that aggregates and reports train run info to TrainStateActor.
This manager class is created on the train controller layer for each run.
"""
def __init__(self, state_actor) -> None:
self.state_actor = state_actor
self.train_run_info_dict = defaultdict(dict)
def register_train_run(
self,
run_id: str,
job_id: str,
run_name: str,
run_status: str,
controller_actor_id: str,
datasets: Dict[str, "Dataset"],
worker_group: WorkerGroup,
start_time_ms: float,
resources: List[Dict[str, float]],
status_detail: str = "",
) -> None:
"""Collect Train Run Info and report to StateActor."""
if not self.state_actor:
logger.warning(
"Unable to register train run since `TrainStateActor` is not started."
)
return
def collect_train_worker_info():
train_context = ray.train.get_context()
core_context = ray.runtime_context.get_runtime_context()
return TrainWorkerInfo(
world_rank=train_context.get_world_rank(),
local_rank=train_context.get_local_rank(),
node_rank=train_context.get_node_rank(),
actor_id=core_context.get_actor_id(),
node_id=core_context.get_node_id(),
node_ip=ray.util.get_node_ip_address(),
gpu_ids=ray.get_gpu_ids(),
pid=os.getpid(),
resources=resources[0],
status=ActorStatusEnum.ALIVE,
)
futures = [
worker_group.execute_single_async(index, collect_train_worker_info)
for index in range(len(worker_group))
]
success, exception = check_for_failure(futures)
if not success:
logger.error(
"Failed to collect run information from the Ray Train "
f"workers:\n{exception}"
)
return
worker_info_list = ray.get(futures)
worker_info_list = sorted(worker_info_list, key=lambda info: info.world_rank)
dataset_info_list = [
TrainDatasetInfo(
name=ds_name,
dataset_name=ds._dataset_name,
dataset_uuid=ds._uuid,
)
for ds_name, ds in datasets.items()
]
updates = dict(
id=run_id,
job_id=job_id,
name=run_name,
controller_actor_id=controller_actor_id,
workers=worker_info_list,
datasets=dataset_info_list,
start_time_ms=start_time_ms,
run_status=run_status,
status_detail=status_detail,
resources=resources,
)
# Clear the cached info to avoid registering the same run twice
self.train_run_info_dict[run_id] = {}
self._update_train_run_info(run_id, updates)
def end_train_run(
self,
run_id: str,
run_status: RunStatusEnum,
status_detail: str,
end_time_ms: int,
):
"""Update the train run status when the training is finished."""
updates = dict(
run_status=run_status,
status_detail=status_detail,
end_time_ms=end_time_ms,
)
self._update_train_run_info(run_id, updates)
def _update_train_run_info(self, run_id: str, updates: Dict[str, Any]) -> None:
"""Update specific fields of a registered TrainRunInfo instance."""
if run_id in self.train_run_info_dict:
self.train_run_info_dict[run_id].update(updates)
train_run_info = TrainRunInfo(**self.train_run_info_dict[run_id])
ray.get(self.state_actor.register_train_run.remote(train_run_info))
+736
View File
@@ -0,0 +1,736 @@
# 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
try:
# check if Arrow has S3 support
from pyarrow.fs import S3FileSystem
except ImportError:
S3FileSystem = None
# isort: on
import fnmatch
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type, Union
from ray.air._internal.filelock import TempFileLock
from ray.train._internal.syncer import SyncConfig, Syncer, _BackgroundSyncer
from ray.train.constants import _get_ray_train_session_dir
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.train._checkpoint import Checkpoint
logger = logging.getLogger(__name__)
_VALIDATE_STORAGE_MARKER_FILENAME = ".validate_storage_marker"
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: Forwarded to the ``fsspec.implementations.local.LocalFileSystem``
parent class.
"""
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 S3FileSystem and 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):
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: Optional[Callable[[pyarrow.fs.FileInfo], bool]] = None,
) -> 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.
"""
if file_filter is None:
file_filter = lambda x: True # noqa: E731
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 use.
fs_path: The path on the filesystem to check.
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 tuple of (filesystem, path) resolved from the inputs.
"""
storage_path = str(storage_path)
if storage_filesystem:
return storage_filesystem, storage_path
return pyarrow.fs.FileSystem.from_uri(storage_path)
class _FilesystemSyncer(_BackgroundSyncer):
"""Syncer between local filesystem and a `storage_filesystem`."""
def __init__(self, storage_filesystem: Optional["pyarrow.fs.FileSystem"], **kwargs):
self.storage_filesystem = storage_filesystem
super().__init__(**kwargs)
def _sync_up_command(
self, local_path: str, uri: str, exclude: Optional[List] = None
) -> Tuple[Callable, Dict]:
# TODO(justinvyu): Defer this cleanup up as part of the
# external-facing Syncer deprecation.
fs_path = uri
return (
_upload_to_fs_path,
dict(
local_path=local_path,
fs=self.storage_filesystem,
fs_path=fs_path,
exclude=exclude,
),
)
def _sync_down_command(self, uri: str, local_path: str) -> Tuple[Callable, Dict]:
fs_path = uri
return (
_download_from_fs_path,
dict(
fs=self.storage_filesystem,
fs_path=fs_path,
local_path=local_path,
),
)
def _delete_command(self, uri: str) -> Tuple[Callable, Dict]:
fs_path = uri
return _delete_fs_path, dict(fs=self.storage_filesystem, fs_path=fs_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'
>>> storage.current_checkpoint_index = 1
>>> storage.checkpoint_fs_path
'bucket/path/exp_name/trial_dir/checkpoint_000001'
>>> 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,
sync_config: Optional[SyncConfig] = None,
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
trial_dir_name: Optional[str] = None,
current_checkpoint_index: int = -1,
):
from ray.tune.utils import date_str
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.trial_dir_name = trial_dir_name
self.current_checkpoint_index = current_checkpoint_index
self.sync_config = sync_config or SyncConfig()
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.syncer: Syncer = _FilesystemSyncer(
storage_filesystem=self.storage_filesystem,
sync_period=self.sync_config.sync_period,
sync_timeout=self.sync_config.sync_timeout,
)
self._create_validation_file()
self._check_validation_file()
# Timestamp is used to create a unique session directory for the current
# training job. This is used to avoid conflicts when multiple training jobs
# run with the same name in the same cluster.
# This is set ONCE at the creation of the storage context, on the driver.
self._timestamp = date_str()
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"
f" trial_dir_name='{self.trial_dir_name}',\n"
f" current_checkpoint_index={self.current_checkpoint_index},\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 _update_checkpoint_index(self, metrics: Dict):
# Per default, increase by 1. This can be overwritten to customize checkpoint
# directories.
self.current_checkpoint_index += 1
def persist_current_checkpoint(self, checkpoint: "Checkpoint") -> "Checkpoint":
"""Persists a given checkpoint to the current checkpoint path on the filesystem.
"Current" is defined by the `current_checkpoint_index` attribute of the
storage context.
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, checkpoint_fs_path).
Returns:
Checkpoint: A Checkpoint pointing to the persisted checkpoint location.
"""
# TODO(justinvyu): Fix this cyclical import.
logger.debug(
"Copying checkpoint files to storage path:\n"
"({source_fs}, {source}) -> ({dest_fs}, {destination})".format(
source=checkpoint.path,
destination=self.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(self.checkpoint_fs_path)
_pyarrow_fs_copy_files(
source=checkpoint.path,
destination=self.checkpoint_fs_path,
source_filesystem=checkpoint.filesystem,
destination_filesystem=self.storage_filesystem,
)
persisted_checkpoint = checkpoint.__class__(
filesystem=self.storage_filesystem,
path=self.checkpoint_fs_path,
)
logger.info(f"Checkpoint successfully created at: {persisted_checkpoint}")
return persisted_checkpoint
def persist_artifacts(self, force: bool = False) -> None:
"""Persists all artifacts within `trial_local_dir` to storage.
This method possibly launches a background task to sync the trial dir,
depending on the `sync_period` + `sync_artifacts_on_checkpoint`
settings of `SyncConfig`.
`(local_fs, trial_working_dir) -> (storage_filesystem, trial_fs_path)`
Args:
force: If True, wait for a previous sync to finish, launch a new one,
and wait for that one to finish. By the end of a `force=True` call, the
latest version of the trial artifacts will be persisted.
"""
if not self.sync_config.sync_artifacts:
return
# Skip if there are no artifacts to sync
is_empty = not any(os.scandir(self.trial_working_directory))
if is_empty:
return
if force:
self.syncer.wait()
self.syncer.sync_up(
local_dir=self.trial_working_directory, remote_dir=self.trial_fs_path
)
self.syncer.wait()
else:
self.syncer.sync_up_if_needed(
local_dir=self.trial_working_directory, remote_dir=self.trial_fs_path
)
@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()
def _get_session_path(self) -> str:
"""The Ray Train/Tune session local directory used to stage files
before persisting to the storage filesystem."""
return Path(
_get_ray_train_session_dir(), self._timestamp, self.experiment_dir_name
).as_posix()
@property
def experiment_driver_staging_path(self) -> str:
"""The local filesystem path of the experiment directory on the driver node.
The driver is the node where `Trainer.fit`/`Tuner.fit` is being called.
This path is of the form:
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
<experiment_dir_name>/driver_artifacts`
This should be used as the temporary staging location for files *on the driver*
before syncing them to `experiment_fs_path`.
For example, the search algorithm should dump its state to this directory.
See `trial_driver_staging_path` for writing trial-specific artifacts.
The directory is synced to
`{storage_path}/{experiment_dir_name}` periodically.
See `_ExperimentCheckpointManager.checkpoint` for where that happens.
"""
return Path(self._get_session_path(), "driver_artifacts").as_posix()
@property
def trial_fs_path(self) -> str:
"""The trial directory path on the `storage_filesystem`.
Raises a ValueError if `trial_dir_name` is not set beforehand.
"""
if self.trial_dir_name is None:
raise RuntimeError(
"Should not access `trial_fs_path` without setting `trial_dir_name`"
)
return Path(self.experiment_fs_path, self.trial_dir_name).as_posix()
@property
def trial_driver_staging_path(self) -> str:
"""The local filesystem path of the trial directory on the driver.
The driver is the node where `Trainer.fit`/`Tuner.fit` is being called.
This path is of the form:
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
<experiment_dir_name>/driver_artifacts/<trial_dir_name>`
This should be used as the temporary location for files on the driver
before persisting them to `trial_fs_path`.
For example, callbacks (e.g., JsonLoggerCallback) should write trial-specific
logfiles within this directory.
"""
if self.trial_dir_name is None:
raise RuntimeError(
"Should not access `trial_driver_staging_path` "
"without setting `trial_dir_name`"
)
return Path(self.experiment_driver_staging_path, self.trial_dir_name).as_posix()
@property
def trial_working_directory(self) -> str:
"""The local filesystem path to trial working directory.
This path is of the form:
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
<experiment_dir_name>/working_dirs/<trial_dir_name>`
Ray Train/Tune moves the remote actor's working directory to this path
by default, unless disabled by `RAY_CHDIR_TO_TRIAL_DIR` environment variable.
Writing files to this directory allows users to persist training artifacts
if `SyncConfig(sync_artifacts=True)` is set.
"""
if self.trial_dir_name is None:
raise RuntimeError(
"Cannot access `trial_working_directory` without "
"setting `trial_dir_name`"
)
return Path(
self._get_session_path(), "working_dirs", self.trial_dir_name
).as_posix()
@property
def checkpoint_fs_path(self) -> str:
"""The current checkpoint directory path on the `storage_filesystem`.
"Current" refers to the checkpoint that is currently being created/persisted.
The user of this class is responsible for setting the `current_checkpoint_index`
(e.g., incrementing when needed).
"""
return Path(self.trial_fs_path, self.checkpoint_dir_name).as_posix()
@property
def checkpoint_dir_name(self) -> str:
"""The current checkpoint directory name, based on the checkpoint index."""
return StorageContext._make_checkpoint_dir_name(self.current_checkpoint_index)
@staticmethod
def get_experiment_dir_name(run_obj: Union[str, Callable, Type]) -> str:
from ray.tune.experiment import Experiment
from ray.tune.utils import date_str
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_checkpoint_dir_name(index: int):
"""Get the name of the checkpoint directory, given an index."""
return f"checkpoint_{index:06d}"
+429
View File
@@ -0,0 +1,429 @@
import abc
import logging
import threading
import time
import traceback
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
from ray._private.thirdparty.tabulate.tabulate import tabulate
from ray.util.annotations import Deprecated, DeveloperAPI
from ray.widgets import Template
logger = logging.getLogger(__name__)
# Syncing period for syncing checkpoints between nodes or to cloud.
DEFAULT_SYNC_PERIOD = 300
# Default sync timeout after which syncing processes are aborted
DEFAULT_SYNC_TIMEOUT = 1800
@Deprecated
@dataclass
class SyncConfig:
sync_period: int = DEFAULT_SYNC_PERIOD
sync_timeout: int = DEFAULT_SYNC_TIMEOUT
sync_artifacts: bool = False
sync_artifacts_on_checkpoint: bool = True
def _repr_html_(self) -> str:
"""Generate an HTML representation of the SyncConfig."""
return Template("scrollableTable.html.j2").render(
table=tabulate(
{
"Setting": ["Sync period", "Sync timeout"],
"Value": [self.sync_period, self.sync_timeout],
},
tablefmt="html",
showindex=False,
headers="keys",
),
max_height="none",
)
class _BackgroundProcess:
def __init__(self, fn: Callable):
self._fn = fn
self._process = None
self._result = {}
self._start_time = float("-inf")
@property
def is_running(self):
return self._process and self._process.is_alive()
@property
def start_time(self):
return self._start_time
def start(self, *args, **kwargs):
if self.is_running:
return False
self._result = {}
def entrypoint():
try:
result = self._fn(*args, **kwargs)
except Exception as e:
self._result["exception"] = e
return
self._result["result"] = result
self._process = threading.Thread(target=entrypoint)
self._process.daemon = True
self._process.start()
self._start_time = time.time()
def wait(self, timeout: Optional[float] = None) -> Any:
"""Waits for the background process to finish running. Waits until the
background process has run for at least `timeout` seconds, counting from
the time when the process was started."""
if not self._process:
return None
time_remaining = None
if timeout:
elapsed = time.time() - self.start_time
time_remaining = max(timeout - elapsed, 0)
self._process.join(timeout=time_remaining)
if self._process.is_alive():
self._process = None
raise TimeoutError(
f"{getattr(self._fn, '__name__', str(self._fn))} did not finish "
f"running within the timeout of {timeout} seconds."
)
self._process = None
exception = self._result.get("exception")
if exception:
raise exception
result = self._result.get("result")
self._result = {}
return result
@DeveloperAPI
class Syncer(abc.ABC):
"""Syncer class for synchronizing data between Ray nodes and remote (cloud) storage.
This class handles data transfer for two cases:
1. Synchronizing data such as experiment state snapshots from the driver to
cloud storage.
2. Synchronizing data such as trial checkpoints from remote trainables to
cloud storage.
Synchronizing tasks are usually asynchronous and can be awaited using ``wait()``.
The base class implements a ``wait_or_retry()`` API that will retry a failed
sync command.
The base class also exposes an API to only kick off syncs every ``sync_period``
seconds.
Args:
sync_period: The minimum time in seconds between sync operations, as
used by ``sync_up/down_if_needed``.
sync_timeout: The maximum time to wait for a sync process to finish before
issuing a new sync operation. Ex: should be used by ``wait`` if launching
asynchronous sync tasks.
"""
def __init__(
self,
sync_period: float = DEFAULT_SYNC_PERIOD,
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
):
self.sync_period = sync_period
self.sync_timeout = sync_timeout
self.last_sync_up_time = float("-inf")
self.last_sync_down_time = float("-inf")
@abc.abstractmethod
def sync_up(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
"""Synchronize local directory to remote directory.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
local_dir: Local directory to sync from.
remote_dir: Remote directory to sync up to. This is an URI
(``protocol://remote/path``).
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
@abc.abstractmethod
def sync_down(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
) -> bool:
"""Synchronize remote directory to local directory.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
remote_dir: Remote directory to sync down from. This is an URI
(``protocol://remote/path``).
local_dir: Local directory to sync to.
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
@abc.abstractmethod
def delete(self, remote_dir: str) -> bool:
"""Delete directory on remote storage.
This function can spawn an asynchronous process that can be awaited in
``wait()``.
Args:
remote_dir: Remote directory to delete. This is an URI
(``protocol://remote/path``).
Returns:
True if sync process has been spawned, False otherwise.
"""
raise NotImplementedError
def retry(self):
"""Retry the last sync up, sync down, or delete command.
You should implement this method if you spawn asynchronous syncing
processes.
"""
pass
def wait(self, timeout: Optional[float] = None):
"""Wait for asynchronous sync command to finish.
You should implement this method if you spawn asynchronous syncing
processes. This method should timeout after the asynchronous command
has run for `sync_timeout` seconds and raise a `TimeoutError`.
"""
pass
def sync_up_if_needed(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
"""Syncs up if time since last sync up is greater than sync_period.
Args:
local_dir: Local directory to sync from.
remote_dir: Remote directory to sync up to. This is an URI
(``protocol://remote/path``).
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
The result of ``sync_up`` if a sync was triggered, otherwise ``None``.
"""
now = time.time()
if now - self.last_sync_up_time >= self.sync_period:
result = self.sync_up(
local_dir=local_dir, remote_dir=remote_dir, exclude=exclude
)
self.last_sync_up_time = now
return result
def sync_down_if_needed(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
):
"""Syncs down if time since last sync down is greater than sync_period.
Args:
remote_dir: Remote directory to sync down from. This is an URI
(``protocol://remote/path``).
local_dir: Local directory to sync to.
exclude: Pattern of files to exclude, e.g.
``["*/checkpoint_*]`` to exclude trial checkpoints.
Returns:
The result of ``sync_down`` if a sync was triggered, otherwise ``None``.
"""
now = time.time()
if now - self.last_sync_down_time >= self.sync_period:
result = self.sync_down(
remote_dir=remote_dir, local_dir=local_dir, exclude=exclude
)
self.last_sync_down_time = now
return result
def wait_or_retry(self, max_retries: int = 2, backoff_s: int = 5):
assert max_retries > 0
last_error_traceback = None
for i in range(max_retries + 1):
try:
self.wait()
except Exception as e:
attempts_remaining = max_retries - i
# If we're out of retries, then save the full traceback of the last
# error and show it when raising an exception.
if attempts_remaining == 0:
last_error_traceback = traceback.format_exc()
break
logger.error(
f"The latest sync operation failed with the following error: "
f"{repr(e)}\n"
f"Retrying {attempts_remaining} more time(s) after sleeping "
f"for {backoff_s} seconds..."
)
time.sleep(backoff_s)
self.retry()
continue
# Succeeded!
return
raise RuntimeError(
f"Failed sync even after {max_retries} retries. "
f"The latest sync failed with the following error:\n{last_error_traceback}"
)
def reset(self):
self.last_sync_up_time = float("-inf")
self.last_sync_down_time = float("-inf")
def close(self):
pass
def _repr_html_(self) -> str:
return
class _BackgroundSyncer(Syncer):
"""Syncer using a background process for asynchronous file transfer."""
def __init__(
self,
sync_period: float = DEFAULT_SYNC_PERIOD,
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
):
super(_BackgroundSyncer, self).__init__(
sync_period=sync_period, sync_timeout=sync_timeout
)
self._sync_process = None
self._current_cmd = None
def _should_continue_existing_sync(self):
"""Returns whether a previous sync is still running within the timeout."""
return (
self._sync_process
and self._sync_process.is_running
and time.time() - self._sync_process.start_time < self.sync_timeout
)
def _launch_sync_process(self, sync_command: Tuple[Callable, Dict]):
"""Waits for the previous sync process to finish,
then launches a new process that runs the given command."""
if self._sync_process:
try:
self.wait()
except Exception:
logger.warning(
f"Last sync command failed with the following error:\n"
f"{traceback.format_exc()}"
)
self._current_cmd = sync_command
self.retry()
def sync_up(
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
) -> bool:
if self._should_continue_existing_sync():
logger.debug(
f"Last sync still in progress, "
f"skipping sync up of {local_dir} to {remote_dir}"
)
return False
sync_up_cmd = self._sync_up_command(
local_path=local_dir, uri=remote_dir, exclude=exclude
)
self._launch_sync_process(sync_up_cmd)
return True
def _sync_up_command(
self, local_path: str, uri: str, exclude: Optional[List] = None
) -> Tuple[Callable, Dict]:
raise NotImplementedError
def sync_down(
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
) -> bool:
if self._should_continue_existing_sync():
logger.warning(
f"Last sync still in progress, "
f"skipping sync down of {remote_dir} to {local_dir}"
)
return False
sync_down_cmd = self._sync_down_command(uri=remote_dir, local_path=local_dir)
self._launch_sync_process(sync_down_cmd)
return True
def _sync_down_command(self, uri: str, local_path: str) -> Tuple[Callable, Dict]:
raise NotImplementedError
def delete(self, remote_dir: str) -> bool:
if self._should_continue_existing_sync():
logger.warning(
f"Last sync still in progress, skipping deletion of {remote_dir}"
)
return False
delete_cmd = self._delete_command(uri=remote_dir)
self._launch_sync_process(delete_cmd)
return True
def _delete_command(self, uri: str) -> Tuple[Callable, Dict]:
raise NotImplementedError
def wait(self, timeout: Optional[float] = None):
if self._sync_process:
try:
self._sync_process.wait(timeout=timeout or self.sync_timeout)
except Exception as e:
raise e
finally:
# Regardless of whether the sync process succeeded within the timeout,
# clear the sync process so a new one can be created.
self._sync_process = None
def retry(self):
if not self._current_cmd:
raise RuntimeError("No sync command set, cannot retry.")
cmd, kwargs = self._current_cmd
self._sync_process = _BackgroundProcess(cmd)
self._sync_process.start(**kwargs)
def __getstate__(self):
state = self.__dict__.copy()
state["_sync_process"] = None
return state
+230
View File
@@ -0,0 +1,230 @@
import abc
import functools
import inspect
import logging
import os
import socket
from typing import (
Any,
Callable,
ContextManager,
Dict,
List,
Optional,
Tuple,
TypeVar,
Union,
)
import ray
from ray._common.network_utils import find_free_port, is_ipv6
from ray.actor import ActorHandle
from ray.air._internal.util import (
StartTraceback,
StartTracebackWithWorkerRank,
)
from ray.exceptions import RayActorError
from ray.types import ObjectRef
T = TypeVar("T")
logger = logging.getLogger(__name__)
def check_for_failure(
remote_values: List[ObjectRef],
) -> Tuple[bool, Optional[Exception]]:
"""Check for actor failure when retrieving the remote values.
Args:
remote_values: List of object references from Ray actor methods.
Returns:
A tuple of (bool, Exception). The bool is
True if evaluating all object references is successful, False otherwise.
"""
unfinished = remote_values.copy()
while len(unfinished) > 0:
finished, unfinished = ray.wait(unfinished)
# If a failure occurs the ObjectRef will be marked as finished.
# Calling ray.get will expose the failure as a RayActorError.
for object_ref in finished:
# Everything in finished has either failed or completed
# successfully.
try:
ray.get(object_ref)
except RayActorError as exc:
failed_actor_rank = remote_values.index(object_ref)
logger.info(f"Worker {failed_actor_rank} has failed.")
return False, exc
except Exception as exc:
# Other (e.g. training) errors should be directly raised
failed_worker_rank = remote_values.index(object_ref)
raise StartTracebackWithWorkerRank(
worker_rank=failed_worker_rank
) from exc
return True, None
def get_address_and_port() -> Tuple[str, int]:
"""Returns the IP address and a free port on this node."""
addr = ray.util.get_node_ip_address()
port = find_free_port(socket.AF_INET6 if is_ipv6(addr) else socket.AF_INET)
return addr, port
def update_env_vars(env_vars: Dict[str, Any]):
"""Updates the environment variables on this worker process.
Args:
env_vars: Environment variables to set.
"""
sanitized = {k: str(v) for k, v in env_vars.items()}
os.environ.update(sanitized)
def count_required_parameters(fn: Callable) -> int:
"""Counts the number of required parameters of a function.
NOTE: *args counts as 1 required parameter.
Args:
fn: The function whose required parameters should be counted.
Returns:
The number of required parameters of ``fn``.
Examples:
>>> def fn(a, b, /, c, *args, d=1, e=2, **kwargs):
... pass
>>> count_required_parameters(fn)
4
>>> fn = lambda: 1
>>> count_required_parameters(fn)
0
>>> def fn(config, a, b=1, c=2):
... pass
>>> from functools import partial
>>> count_required_parameters(partial(fn, a=0))
1
"""
params = inspect.signature(fn).parameters.values()
positional_param_kinds = {
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.VAR_POSITIONAL,
}
return len(
[
p
for p in params
if p.default == inspect.Parameter.empty and p.kind in positional_param_kinds
]
)
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_func",
discard_returns: bool = False,
) -> 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.
discard_returns: Whether to discard any returns from train_func or not.
Returns:
A valid training function.
Raises:
ValueError: if the input ``train_func`` is invalid.
"""
num_required_params = count_required_parameters(train_func)
if discard_returns:
# Discard any returns from the function so that
# BackendExecutor doesn't try to deserialize them.
# Those returns are inaccesible with AIR anyway.
@functools.wraps(train_func)
def discard_return_wrapper(*args, **kwargs):
try:
train_func(*args, **kwargs)
except Exception as e:
raise StartTraceback from e
wrapped_train_func = discard_return_wrapper
else:
wrapped_train_func = 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)
elif num_required_params == 1:
config = {} if config is None else config
@functools.wraps(wrapped_train_func)
def train_fn():
try:
with train_func_context():
return wrapped_train_func(config)
except Exception as e:
raise StartTraceback from e
else: # num_params == 0
@functools.wraps(wrapped_train_func)
def train_fn():
try:
with train_func_context():
return wrapped_train_func()
except Exception as e:
raise StartTraceback from e
return train_fn
class Singleton(abc.ABCMeta):
"""Singleton Abstract Base Class
https://stackoverflow.com/questions/33364070/implementing
-singleton-as-metaclass-but-for-abstract-classes
"""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class ActorWrapper:
"""Wraps an actor to provide same API as using the base class directly."""
def __init__(self, actor: ActorHandle):
self.actor = actor
def __getattr__(self, item):
# The below will fail if trying to access an attribute (not a method) from the
# actor.
actor_method = getattr(self.actor, item)
return lambda *args, **kwargs: ray.get(actor_method.remote(*args, **kwargs))
+445
View File
@@ -0,0 +1,445 @@
import copy
import logging
import os
import socket
from collections import defaultdict
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
import ray
from ray.actor import ActorHandle
from ray.air._internal.util import exception_cause, skip_exceptions
from ray.train._internal.base_worker_group import BaseWorkerGroup
from ray.types import ObjectRef
from ray.util.placement_group import PlacementGroup
T = TypeVar("T")
logger = logging.getLogger(__name__)
class RayTrainWorker:
"""A class to execute arbitrary functions. Does not hold any state."""
def __execute(self, func: Callable[..., T], *args, **kwargs) -> T:
"""Executes the input function and returns the output.
Args:
func: The function to execute.
*args: Positional arguments to pass into ``func``.
**kwargs: Keyword arguments to pass into ``func``.
Returns:
The result of calling ``func`` with the provided arguments.
"""
try:
return func(*args, **kwargs)
except Exception as e:
skipped = skip_exceptions(e)
raise skipped from exception_cause(skipped)
@dataclass
class WorkerMetadata:
"""Metadata for each worker/actor.
This information is expected to stay the same throughout the lifetime of
actor.
Args:
node_id: ID of the node this worker is on.
node_ip: IP address of the node this worker is on.
hostname: Hostname that this worker is on.
resource_ids: Map of accelerator resources
("GPU", "neuron_cores", ..) to their IDs.
pid: Process ID of this worker.
"""
node_id: str
node_ip: str
hostname: str
resource_ids: Dict[str, List[str]]
pid: int
@dataclass
class Worker:
"""Class representing a Worker."""
actor: ActorHandle
metadata: WorkerMetadata
def create_executable_class(executable_cls: Optional[Type] = None) -> Type:
"""Create the executable class to use as the Ray actors."""
if not executable_cls:
return RayTrainWorker
elif issubclass(executable_cls, RayTrainWorker):
return executable_cls
else:
class _WrappedExecutable(executable_cls, RayTrainWorker):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
return _WrappedExecutable
def construct_metadata() -> WorkerMetadata:
"""Creates metadata for this worker.
This function is expected to be run on the actor.
"""
node_id = ray.get_runtime_context().get_node_id()
node_ip = ray.util.get_node_ip_address()
hostname = socket.gethostname()
accelerator_ids = ray.get_runtime_context().get_accelerator_ids()
pid = os.getpid()
return WorkerMetadata(
node_id=node_id,
node_ip=node_ip,
hostname=hostname,
resource_ids=accelerator_ids,
pid=pid,
)
class WorkerGroup(BaseWorkerGroup):
"""Group of Ray Actors that can execute arbitrary functions.
``WorkerGroup`` launches Ray actors according to the given
specification. It can then execute arbitrary Python functions in each of
these workers.
If not enough resources are available to launch the actors, the Ray
cluster will automatically scale up if autoscaling is enabled.
Args:
num_workers: The number of workers (Ray actors) to launch.
Defaults to 1.
resources_per_worker: Dictionary specifying the resources that will be
requested for each worker. Defaults to {"CPU": 1}.
actor_cls: If specified use this class as the remote actors.
actor_cls_args: If ``actor_cls`` is provided, these positional args will
be used for the worker initialization.
actor_cls_kwargs: If ``actor_cls`` is provided, these keyword args will
be used for the worker initialization.
placement_group: The placement group that workers
should be created in. Defaults to "default" which will inherit the
parent placement group (if child tasks should be captured).
Example:
.. code_block:: python
worker_group = WorkerGroup(num_workers=2)
output = worker_group.execute(lambda: 1)
assert len(output) == 2
assert all(o == 1 for o in output)
"""
def __init__(
self,
num_workers: int = 1,
resources_per_worker: Optional[Dict[str, float]] = None,
actor_cls: Type = None,
actor_cls_args: Optional[Tuple] = None,
actor_cls_kwargs: Optional[Dict] = None,
placement_group: Union[PlacementGroup, str] = "default",
):
if resources_per_worker is None:
resources_per_worker = {"CPU": 1}
else:
resources_per_worker = resources_per_worker.copy()
if num_workers <= 0:
raise ValueError(
"The provided `num_workers` must be greater "
f"than 0. Received num_workers={num_workers} "
f"instead."
)
if any(v < 0 for v in resources_per_worker.values()):
raise ValueError(
"The number of resources per worker must not be negative. "
f"Received resources_per_worker={resources_per_worker}."
)
if (actor_cls_args or actor_cls_kwargs) and not actor_cls:
raise ValueError(
"`actor_cls_args` or `actor_class_kwargs` are "
"passed in but no `actor_cls` is passed in."
)
self.num_workers = num_workers
self.resources_per_worker = resources_per_worker
_resources_per_worker = copy.deepcopy(resources_per_worker)
self.num_cpus_per_worker = _resources_per_worker.pop("CPU", 0)
self.num_gpus_per_worker = _resources_per_worker.pop("GPU", 0)
self.memory_per_worker = _resources_per_worker.pop("memory", 0)
self.workers = []
self._base_cls = create_executable_class(actor_cls)
assert issubclass(self._base_cls, RayTrainWorker)
self._actor_cls_args = actor_cls_args or []
self._actor_cls_kwargs = actor_cls_kwargs or {}
self._placement_group = placement_group
# TODO(matt): Validate resources. Fast-fail if it is impossible to
# handle the request, rather than hang indefinitely.
self._remote_cls = ray.remote(
num_cpus=self.num_cpus_per_worker,
num_gpus=self.num_gpus_per_worker,
memory=self.memory_per_worker,
resources=_resources_per_worker,
)(self._base_cls)
self.start()
def start(self):
"""Starts all the workers in this worker group."""
if self.workers and len(self.workers) > 0:
raise RuntimeError(
"The workers have already been started. "
"Please call `shutdown` first if you want to "
"restart them."
)
logger.debug(f"Starting {self.num_workers} workers.")
self.add_workers(self.num_workers)
logger.debug(f"{len(self.workers)} workers have successfully started.")
def shutdown(self, patience_s: float = 5):
"""Shutdown all the workers in this worker group.
Args:
patience_s: Attempt a graceful shutdown
of the workers for this many seconds. Fallback to force kill
if graceful shutdown is not complete after this time. If
this is less than or equal to 0, immediately force kill all
workers.
"""
logger.debug(f"Shutting down {len(self.workers)} workers.")
if patience_s <= 0:
for worker in self.workers:
ray.kill(worker.actor)
else:
done_refs = [w.actor.__ray_terminate__.remote() for w in self.workers]
# Wait for actors to die gracefully.
done, not_done = ray.wait(done_refs, timeout=patience_s)
if not_done:
logger.debug("Graceful termination failed. Falling back to force kill.")
# If all actors are not able to die gracefully, then kill them.
for worker in self.workers:
ray.kill(worker.actor)
logger.debug("Shutdown successful.")
self.workers = []
def execute_async(self, func: Callable[..., T], *args, **kwargs) -> List[ObjectRef]:
"""Execute ``func`` on each worker and return the futures.
Args:
func: A function to call on each worker.
*args: Positional arguments passed directly into ``func``.
**kwargs: Keyword arguments passed directly into ``func``.
Returns:
(List[ObjectRef]) A list of ``ObjectRef`` representing the
output of ``func`` from each worker. The order is the same
as ``self.workers``.
"""
if len(self.workers) <= 0:
raise RuntimeError(
"There are no active workers. This worker "
"group has most likely been shut down. Please"
"create a new WorkerGroup or restart this one."
)
return [
w.actor._RayTrainWorker__execute.options(
name=f"_RayTrainWorker__execute.{func.__name__}"
).remote(func, *args, **kwargs)
for w in self.workers
]
def execute(self, func: Callable[..., T], *args, **kwargs) -> List[T]:
"""Execute ``func`` on each worker and return the outputs of ``func``.
Args:
func: A function to call on each worker.
*args: Positional arguments passed directly into ``func``.
**kwargs: Keyword arguments passed directly into ``func``.
Returns:
(List[T]) A list containing the output of ``func`` from each
worker. The order is the same as ``self.workers``.
"""
# TODO: Add a timeout in the case of a hang, particularly
# relevant when func is TorchConfig.on_shutdown
return ray.get(self.execute_async(func, *args, **kwargs))
def execute_single_async(
self, worker_index: int, func: Callable[..., T], *args, **kwargs
) -> ObjectRef:
"""Execute ``func`` on worker ``worker_index`` and return futures.
Args:
worker_index: The index to execute func on.
func: A function to call on the first worker.
*args: Positional arguments passed directly into ``func``.
**kwargs: Keyword arguments passed directly into ``func``.
Returns:
(ObjectRef) An ObjectRef representing the output of func.
"""
if worker_index >= len(self.workers):
raise ValueError(
f"The provided worker_index {worker_index} is "
f"not valid for {self.num_workers} workers."
)
return (
self.workers[worker_index]
.actor._RayTrainWorker__execute.options(
name=f"_RayTrainWorker__execute.{func.__name__}"
)
.remote(func, *args, **kwargs)
)
def execute_single(
self, worker_index: int, func: Callable[..., T], *args, **kwargs
) -> T:
"""Execute ``func`` on worker with index ``worker_index``.
Args:
worker_index: The index to execute func on.
func: A function to call on the first worker.
*args: Positional arguments passed directly into ``func``.
**kwargs: Keyword arguments passed directly into ``func``.
Returns:
(T) The output of func.
"""
return ray.get(self.execute_single_async(worker_index, func, *args, **kwargs))
def remove_workers(self, worker_indexes: List[int]):
"""Removes the workers with the specified indexes.
The removed workers will go out of scope and their actor processes
will be terminated.
Args:
worker_indexes: The indexes of the workers to remove.
"""
new_workers = []
for i in range(len(self.workers)):
if i not in worker_indexes:
new_workers.append(self.workers[i])
self.workers = new_workers
def add_workers(self, num_workers: int):
"""Adds ``num_workers`` to this WorkerGroup.
Note: Adding workers when the cluster/placement group is at capacity
may lead to undefined hanging behavior. If you are attempting to
replace existing workers in the WorkerGroup, remove_workers() should
be called first.
Args:
num_workers: The number of workers to add.
"""
new_actors = []
new_actor_metadata = []
for _ in range(num_workers):
actor = self._remote_cls.options(
placement_group=self._placement_group
).remote(*self._actor_cls_args, **self._actor_cls_kwargs)
new_actors.append(actor)
new_actor_metadata.append(
actor._RayTrainWorker__execute.options(
name="_RayTrainWorker__execute.construct_metadata"
).remote(construct_metadata)
)
# Get metadata from all actors.
metadata = ray.get(new_actor_metadata)
for i in range(len(new_actors)):
self.workers.append(Worker(actor=new_actors[i], metadata=metadata[i]))
def sort_workers_by_node_id_and_gpu_id(self, _first_node_id: Optional[str] = None):
"""Reorder the workers by their node id and the lowest GPU id.
This is useful for collocating workers on the same node.
Example:
Given workers with the following attributes:
worker_0: node_id=1, gpu_ids=[1]
worker_1: node_id=0, gpu_ids=[0]
worker_2: node_id=1, gpu_ids=[0]
worker_3: node_id=0, gpu_ids=[1]
The function will perform the following steps:
1. Group by node ID:
node_id=0: worker_1, worker_3
node_id=1: worker_0, worker_2
2. Sort each group by GPU ID:
node_id=0: worker_1 (gpu_id=0), worker_3 (gpu_id=1)
node_id=1: worker_2 (gpu_id=0), worker_0 (gpu_id=1)
Resulting in the order: [worker_1, worker_3, worker_2, worker_0]
Args:
_first_node_id: The first ID to group by.
Set this to the node ID of the trainer coordinator to ensure that the
rank 0 worker is on the same node, allowing additional resources to
be specified for rank 0 workers via
`ScalingConfig(trainer_resources=)`.
"""
node_id_to_workers = defaultdict(list)
if _first_node_id is not None:
node_id_to_workers[_first_node_id] = []
for worker in self.workers:
node_id_to_workers[worker.metadata.node_id].append(worker)
# Sort workers on the same node by the lowest GPU id
# More details: https://github.com/ray-project/ray/issues/40803
def get_lowest_gpu_id(worker) -> int:
gpu_ids = worker.metadata.resource_ids.get("GPU", [])
# If there are no GPU IDs, return 0 as a default
if not gpu_ids:
return 0
# Attempt to convert GPU IDs to integers and find the minimum ID.
# Fallback to return the minimum string-based ID
try:
return min(int(gpu_id) for gpu_id in gpu_ids)
except ValueError:
return min(gpu_ids)
for node_id in node_id_to_workers:
node_id_to_workers[node_id].sort(key=get_lowest_gpu_id)
sorted_workers = []
for workers in node_id_to_workers.values():
sorted_workers.extend(workers)
self.workers = sorted_workers
def __len__(self):
return len(self.workers)
def get_resources_per_worker(self) -> dict:
"""Get the resources allocated per worker."""
return copy.deepcopy(self.resources_per_worker)