446 lines
16 KiB
Python
446 lines
16 KiB
Python
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)
|