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