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
+12
View File
@@ -0,0 +1,12 @@
from ray.air.execution.resources.fixed import FixedResourceManager
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
from ray.air.execution.resources.resource_manager import ResourceManager
__all__ = [
"ResourceRequest",
"AcquiredResources",
"ResourceManager",
"FixedResourceManager",
"PlacementGroupResourceManager",
]
@@ -0,0 +1,5 @@
from ray.air.execution._internal.actor_manager import RayActorManager
from ray.air.execution._internal.barrier import Barrier
from ray.air.execution._internal.tracked_actor import TrackedActor
__all__ = ["Barrier", "RayActorManager", "TrackedActor"]
@@ -0,0 +1,906 @@
import logging
import random
import time
import uuid
from collections import Counter, defaultdict
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
import ray
from ray.air.execution._internal.event_manager import RayEventManager
from ray.air.execution._internal.tracked_actor import TrackedActor
from ray.air.execution._internal.tracked_actor_task import TrackedActorTask
from ray.air.execution.resources import (
AcquiredResources,
ResourceManager,
ResourceRequest,
)
from ray.exceptions import RayActorError, RayTaskError
logger = logging.getLogger(__name__)
class RayActorManager:
"""Management class for Ray actors and actor tasks.
This class provides an event-based management interface for actors, and
actor tasks.
The manager can be used to start actors, stop actors, and schedule and
track task futures on these actors.
The manager will then invoke callbacks related to the tracked entities.
For instance, when an actor is added with
:meth:`add_actor() <RayActorManager.add_actor>`,
a :ref:`TrackedActor <ray.air.execution._internal.tracked_actor.TrackedActor`
object is returned. An ``on_start`` callback can be specified that is invoked
once the actor successfully started. Similarly, ``on_stop`` and ``on_error``
can be used to specify callbacks relating to the graceful or ungraceful
end of an actor's lifetime.
When scheduling an actor task using
:meth:`schedule_actor_task()
<ray.air.execution._internal.actor_manager.RayActorManager.schedule_actor_task>`,
an ``on_result`` callback can be specified that is invoked when the task
successfully resolves, and an ``on_error`` callback will resolve when the
task fails.
The RayActorManager does not implement any true asynchronous processing. Control
has to be explicitly yielded to the event manager via :meth:`RayActorManager.next`.
Callbacks will only be invoked when control is with the RayActorManager, and
callbacks will always be executed sequentially in order of arriving events.
Args:
resource_manager: Resource manager used to request resources for the actors.
Example:
.. code-block:: python
from ray.air.execution import ResourceRequest
from ray.air.execution._internal import RayActorManager
actor_manager = RayActorManager()
# Request an actor
tracked_actor = actor_manager.add_actor(
ActorClass,
kwargs={},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=actor_start_callback,
on_stop=actor_stop_callback,
on_error=actor_error_callback
)
# Yield control to event manager to start actor
actor_manager.next()
# Start task on the actor (ActorClass.foo.remote())
tracked_actor_task = actor_manager.schedule_actor_task(
tracked_actor,
method_name="foo",
on_result=task_result_callback,
on_error=task_error_callback
)
# Again yield control to event manager to process task futures
actor_manager.wait()
"""
def __init__(self, resource_manager: ResourceManager):
self._resource_manager: ResourceManager = resource_manager
self._actor_state_events = RayEventManager()
self._actor_task_events = RayEventManager()
# ---
# Tracked actor futures.
# This maps TrackedActor objects to their futures. We use this to see if an
# actor has any futures scheduled and to remove them when we terminate an actor.
# Actors to actor task futures
self._tracked_actors_to_task_futures: Dict[
TrackedActor, Set[ray.ObjectRef]
] = defaultdict(set)
# Actors to actor state futures (start/terminate)
self._tracked_actors_to_state_futures: Dict[
TrackedActor, Set[ray.ObjectRef]
] = defaultdict(set)
# ---
# Pending actors.
# We use three dicts for actors that are requested but not yet started.
# This dict keeps a list of actors associated with each resource request.
# We use this to start actors in the correct order when their resources
# become available.
self._resource_request_to_pending_actors: Dict[
ResourceRequest, List[TrackedActor]
] = defaultdict(list)
# This dict stores the actor class, kwargs, and resource request of
# pending actors. Once the resources are available, we start the remote
# actor class with its args. We need the resource request to cancel it
# if needed.
self._pending_actors_to_attrs: Dict[
TrackedActor, Tuple[Type, Dict[str, Any], ResourceRequest]
] = {}
# This dict keeps track of cached actor tasks. We can't schedule actor
# tasks before the actor is actually scheduled/live. So when the caller
# tries to schedule a task, we cache it here, and schedule it once the
# actor is started.
self._pending_actors_to_enqueued_actor_tasks: Dict[
TrackedActor, List[Tuple[TrackedActorTask, str, Tuple[Any], Dict[str, Any]]]
] = defaultdict(list)
# ---
# Live actors.
# We keep one dict for actors that are currently running and a set of
# actors that we should forcefully kill.
# This dict associates the TrackedActor object with the Ray actor handle
# and the resources associated to the actor. We use it to schedule the
# actual ray tasks, and to return the resources when the actor stopped.
self._live_actors_to_ray_actors_resources: Dict[
TrackedActor, Tuple[ray.actor.ActorHandle, AcquiredResources]
] = {}
self._live_resource_cache: Optional[Dict[str, Any]] = None
# This dict contains all actors that should be killed (after calling
# `remove_actor()`). Kill requests will be handled in wait().
self._live_actors_to_kill: Set[TrackedActor] = set()
# Track failed actors
self._failed_actor_ids: Set[int] = set()
def next(self, timeout: Optional[Union[int, float]] = None) -> bool:
"""Yield control to event manager to await the next event and invoke callbacks.
Calling this method will wait for up to ``timeout`` seconds for the next
event to arrive.
When events arrive, callbacks relating to the events will be
invoked. A timeout of ``None`` will block until the next event arrives.
Note:
If an actor task fails with a ``RayActorError``, this is one event,
but it may trigger _two_ `on_error` callbacks: One for the actor,
and one for the task.
Note:
The ``timeout`` argument is used for pure waiting time for events. It does
not include time spent on processing callbacks. Depending on the processing
time of the callbacks, it can take much longer for this function to
return than the specified timeout.
Args:
timeout: Timeout in seconds to wait for next event.
Returns:
True if at least one event was processed.
"""
# First issue any pending forceful actor kills
actor_killed = self._try_kill_actor()
# We always try to start actors as this won't trigger an event callback
self._try_start_actors()
# If an actor was killed, this was our event, and we return.
if actor_killed:
return True
# Otherwise, collect all futures and await the next.
resource_futures = self._resource_manager.get_resource_futures()
actor_state_futures = self._actor_state_events.get_futures()
actor_task_futures = self._actor_task_events.get_futures()
# Shuffle state futures
shuffled_state_futures = list(actor_state_futures)
random.shuffle(shuffled_state_futures)
# Shuffle task futures
shuffled_task_futures = list(actor_task_futures)
random.shuffle(shuffled_task_futures)
# Prioritize resource futures over actor state over task futures
all_futures = resource_futures + shuffled_state_futures + shuffled_task_futures
start_wait = time.monotonic()
ready, _ = ray.wait(all_futures, num_returns=1, timeout=timeout)
if not ready:
return False
[future] = ready
if future in actor_state_futures:
self._actor_state_events.resolve_future(future)
elif future in actor_task_futures:
self._actor_task_events.resolve_future(future)
else:
self._handle_ready_resource_future()
# Ready resource futures don't count as one event as they don't trigger
# any callbacks. So we repeat until we hit anything that is not a resource
# future.
time_taken = time.monotonic() - start_wait
return self.next(
timeout=max(1e-9, timeout - time_taken) if timeout is not None else None
)
self._try_start_actors()
return True
def _actor_start_resolved(self, tracked_actor: TrackedActor, future: ray.ObjectRef):
"""Callback to be invoked when actor started"""
self._tracked_actors_to_state_futures[tracked_actor].remove(future)
if tracked_actor._on_start:
tracked_actor._on_start(tracked_actor)
def _actor_stop_resolved(self, tracked_actor: TrackedActor):
"""Callback to be invoked when actor stopped"""
self._cleanup_actor(tracked_actor=tracked_actor)
if tracked_actor._on_stop:
tracked_actor._on_stop(tracked_actor)
def _actor_start_failed(self, tracked_actor: TrackedActor, exception: Exception):
"""Callback to be invoked when actor start/stop failed"""
self._failed_actor_ids.add(tracked_actor.actor_id)
self._cleanup_actor(tracked_actor=tracked_actor)
if tracked_actor._on_error:
tracked_actor._on_error(tracked_actor, exception)
def _actor_task_failed(
self, tracked_actor_task: TrackedActorTask, exception: Exception
):
"""Handle an actor task future that became ready.
- On actor error, trigger actor error callback AND error task error callback
- On task error, trigger actor task error callback
- On success, trigger actor task result callback
"""
tracked_actor = tracked_actor_task._tracked_actor
if isinstance(exception, RayActorError):
self._failed_actor_ids.add(tracked_actor.actor_id)
# Clean up any references to the actor and its futures
self._cleanup_actor(tracked_actor=tracked_actor)
# Handle actor state callbacks
if tracked_actor._on_error:
tracked_actor._on_error(tracked_actor, exception)
# Then trigger actor task error callback
if tracked_actor_task._on_error:
tracked_actor_task._on_error(tracked_actor, exception)
elif isinstance(exception, RayTaskError):
# Otherwise only the task failed. Invoke callback
if tracked_actor_task._on_error:
tracked_actor_task._on_error(tracked_actor, exception)
else:
raise RuntimeError(
f"Caught unexpected exception: {exception}"
) from exception
def _actor_task_resolved(self, tracked_actor_task: TrackedActorTask, result: Any):
tracked_actor = tracked_actor_task._tracked_actor
# Trigger actor task result callback
if tracked_actor_task._on_result:
tracked_actor_task._on_result(tracked_actor, result)
def _handle_ready_resource_future(self):
"""Handle a resource future that became ready.
- Update state of the resource manager
- Try to start one actor
"""
# Force resource manager to update internal state
self._resource_manager.update_state()
# We handle resource futures one by one, so only try to start 1 actor at a time
self._try_start_actors(max_actors=1)
def _try_start_actors(self, max_actors: Optional[int] = None) -> int:
"""Try to start up to ``max_actors`` actors.
This function will iterate through all resource requests we collected for
pending actors. As long as a resource request can be fulfilled (resources
are available), we try to start as many actors as possible.
This will schedule a `Actor.__ray_ready__()` future which, once resolved,
will trigger the `TrackedActor.on_start` callback.
"""
started_actors = 0
# Iterate through all resource requests
for resource_request in self._resource_request_to_pending_actors:
if max_actors is not None and started_actors >= max_actors:
break
# While we have resources ready and there are actors left to schedule
while (
self._resource_manager.has_resources_ready(resource_request)
and self._resource_request_to_pending_actors[resource_request]
):
# Acquire resources for actor
acquired_resources = self._resource_manager.acquire_resources(
resource_request
)
assert acquired_resources
# Get tracked actor to start
candidate_actors = self._resource_request_to_pending_actors[
resource_request
]
assert candidate_actors
tracked_actor = candidate_actors.pop(0)
# Get actor class and arguments
actor_cls, kwargs, _ = self._pending_actors_to_attrs.pop(tracked_actor)
if not isinstance(actor_cls, ray.actor.ActorClass):
actor_cls = ray.remote(actor_cls)
# Associate to acquired resources
[remote_actor_cls] = acquired_resources.annotate_remote_entities(
[actor_cls]
)
# Start Ray actor
actor = remote_actor_cls.remote(**kwargs)
# Track
self._live_actors_to_ray_actors_resources[tracked_actor] = (
actor,
acquired_resources,
)
self._live_resource_cache = None
# Schedule ready future
future = actor.__ray_ready__.remote()
self._tracked_actors_to_state_futures[tracked_actor].add(future)
# We need to create the callbacks in a function so tracked_actors
# are captured correctly.
def create_callbacks(
tracked_actor: TrackedActor, future: ray.ObjectRef
):
def on_actor_start(result: Any):
self._actor_start_resolved(
tracked_actor=tracked_actor, future=future
)
def on_error(exception: Exception):
self._actor_start_failed(
tracked_actor=tracked_actor, exception=exception
)
return on_actor_start, on_error
on_actor_start, on_error = create_callbacks(
tracked_actor=tracked_actor, future=future
)
self._actor_state_events.track_future(
future=future,
on_result=on_actor_start,
on_error=on_error,
)
self._enqueue_cached_actor_tasks(tracked_actor=tracked_actor)
started_actors += 1
return started_actors
def _enqueue_cached_actor_tasks(self, tracked_actor: TrackedActor):
assert tracked_actor in self._live_actors_to_ray_actors_resources
# Enqueue cached futures
cached_tasks = self._pending_actors_to_enqueued_actor_tasks.pop(
tracked_actor, []
)
for tracked_actor_task, method_name, args, kwargs in cached_tasks:
self._schedule_tracked_actor_task(
tracked_actor_task=tracked_actor_task,
method_name=method_name,
args=args,
kwargs=kwargs,
)
def _try_kill_actor(self) -> bool:
"""Try to kill actor scheduled for termination."""
if not self._live_actors_to_kill:
return False
tracked_actor = self._live_actors_to_kill.pop()
# Remove from tracked actors
(
ray_actor,
acquired_resources,
) = self._live_actors_to_ray_actors_resources[tracked_actor]
# Hard kill if requested
ray.kill(ray_actor)
self._cleanup_actor_futures(tracked_actor)
self._actor_stop_resolved(tracked_actor)
return True
def _cleanup_actor(self, tracked_actor: TrackedActor):
self._cleanup_actor_futures(tracked_actor)
# Remove from tracked actors
(
ray_actor,
acquired_resources,
) = self._live_actors_to_ray_actors_resources.pop(tracked_actor)
self._live_resource_cache = None
# Return resources
self._resource_manager.free_resources(acquired_resource=acquired_resources)
@property
def all_actors(self) -> List[TrackedActor]:
"""Return all ``TrackedActor`` objects managed by this manager instance."""
return self.live_actors + self.pending_actors
@property
def live_actors(self) -> List[TrackedActor]:
"""Return all ``TrackedActor`` objects that are currently alive."""
return list(self._live_actors_to_ray_actors_resources)
@property
def pending_actors(self) -> List[TrackedActor]:
"""Return all ``TrackedActor`` objects that are currently pending."""
return list(self._pending_actors_to_attrs)
@property
def num_live_actors(self):
"""Return number of started actors."""
return len(self.live_actors)
@property
def num_pending_actors(self) -> int:
"""Return number of pending (not yet started) actors."""
return len(self.pending_actors)
@property
def num_total_actors(self):
"""Return number of total actors."""
return len(self.all_actors)
@property
def num_actor_tasks(self):
"""Return number of pending tasks"""
return self._actor_task_events.num_futures
def get_live_actors_resources(self):
if self._live_resource_cache:
return self._live_resource_cache
counter = Counter()
for _, acq in self._live_actors_to_ray_actors_resources.values():
for bdl in acq.resource_request.bundles:
counter.update(bdl)
self._live_resource_cache = dict(counter)
return self._live_resource_cache
def add_actor(
self,
cls: Union[Type, ray.actor.ActorClass],
kwargs: Dict[str, Any],
resource_request: ResourceRequest,
*,
on_start: Optional[Callable[[TrackedActor], None]] = None,
on_stop: Optional[Callable[[TrackedActor], None]] = None,
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
) -> TrackedActor:
"""Add an actor to be tracked.
This method will request resources to start the actor. Once the resources
are available, the actor will be started and the
:meth:`TrackedActor.on_start
<ray.air.execution._internal.tracked_actor.TrackedActor.on_start>` callback
will be invoked.
Args:
cls: Actor class to schedule.
kwargs: Keyword arguments to pass to actor class on construction.
resource_request: Resources required to start the actor.
on_start: Callback to invoke when the actor started.
on_stop: Callback to invoke when the actor stopped.
on_error: Callback to invoke when the actor failed.
Returns:
Tracked actor object to reference actor in subsequent API calls.
"""
tracked_actor = TrackedActor(
uuid.uuid4().int, on_start=on_start, on_stop=on_stop, on_error=on_error
)
self._pending_actors_to_attrs[tracked_actor] = cls, kwargs, resource_request
self._resource_request_to_pending_actors[resource_request].append(tracked_actor)
self._resource_manager.request_resources(resource_request=resource_request)
return tracked_actor
def remove_actor(
self,
tracked_actor: TrackedActor,
kill: bool = False,
stop_future: Optional[ray.ObjectRef] = None,
) -> bool:
"""Remove a tracked actor.
If the actor has already been started, this will stop the actor. This will
trigger the :meth:`TrackedActor.on_stop
<ray.air.execution._internal.tracked_actor.TrackedActor.on_stop>`
callback once the actor stopped.
If the actor has only been requested, but not started, yet, this will cancel
the actor request. This will not trigger any callback.
If ``kill=True``, this will use ``ray.kill()`` to forcefully terminate the
actor. Otherwise, graceful actor deconstruction will be scheduled after
all currently tracked futures are resolved.
This method returns a boolean, indicating if a stop future is tracked and
the ``on_stop`` callback will be invoked. If the actor has been alive,
this will be ``True``. If the actor hasn't been scheduled, yet, or failed
(and triggered the ``on_error`` callback), this will be ``False``.
Args:
tracked_actor: Tracked actor to be removed.
kill: If set, will forcefully terminate the actor instead of gracefully
scheduling termination.
stop_future: If set, use this future to track actor termination.
Otherwise, schedule a ``__ray_terminate__`` future.
Returns:
Boolean indicating if the actor was previously alive, and thus whether
a callback will be invoked once it is terminated.
"""
if tracked_actor.actor_id in self._failed_actor_ids:
logger.debug(
f"Tracked actor already failed, no need to remove: {tracked_actor}"
)
return False
elif tracked_actor in self._live_actors_to_ray_actors_resources:
# Ray actor is running.
if not kill:
# Schedule __ray_terminate__ future
ray_actor, _ = self._live_actors_to_ray_actors_resources[tracked_actor]
# Clear state futures here to avoid resolving __ray_ready__ futures
for future in list(
self._tracked_actors_to_state_futures[tracked_actor]
):
self._actor_state_events.discard_future(future)
self._tracked_actors_to_state_futures[tracked_actor].remove(future)
# If the __ray_ready__ future hasn't resolved yet, but we already
# scheduled the actor via Actor.remote(), we just want to stop
# it but not trigger any callbacks. This is in accordance with
# the contract defined in the docstring.
tracked_actor._on_start = None
tracked_actor._on_stop = None
tracked_actor._on_error = None
def on_actor_stop(*args, **kwargs):
self._actor_stop_resolved(tracked_actor=tracked_actor)
if stop_future:
# If the stop future was schedule via the actor manager,
# discard (track it as state future instead).
self._actor_task_events.discard_future(stop_future)
else:
stop_future = ray_actor.__ray_terminate__.remote()
self._actor_state_events.track_future(
future=stop_future,
on_result=on_actor_stop,
on_error=on_actor_stop,
)
self._tracked_actors_to_state_futures[tracked_actor].add(stop_future)
else:
# kill = True
self._live_actors_to_kill.add(tracked_actor)
return True
elif tracked_actor in self._pending_actors_to_attrs:
# Actor is pending, stop
_, _, resource_request = self._pending_actors_to_attrs.pop(tracked_actor)
self._resource_request_to_pending_actors[resource_request].remove(
tracked_actor
)
self._resource_manager.cancel_resource_request(
resource_request=resource_request
)
return False
else:
raise ValueError(f"Unknown tracked actor: {tracked_actor}")
def is_actor_started(self, tracked_actor: TrackedActor) -> bool:
"""Returns True if the actor has been started.
Args:
tracked_actor: Tracked actor object.
Returns:
True if the actor has been started, False otherwise.
"""
return (
tracked_actor in self._live_actors_to_ray_actors_resources
and tracked_actor.actor_id not in self._failed_actor_ids
)
def is_actor_failed(self, tracked_actor: TrackedActor) -> bool:
return tracked_actor.actor_id in self._failed_actor_ids
def get_actor_resources(
self, tracked_actor: TrackedActor
) -> Optional[AcquiredResources]:
"""Returns the acquired resources of an actor that has been started.
This will return ``None`` if the actor has not been started, yet.
Args:
tracked_actor: Tracked actor object.
Returns:
The acquired resources of the actor, or ``None`` if the actor has not
been started yet.
"""
if not self.is_actor_started(tracked_actor):
return None
return self._live_actors_to_ray_actors_resources[tracked_actor][1]
def schedule_actor_task(
self,
tracked_actor: TrackedActor,
method_name: str,
args: Optional[Tuple] = None,
kwargs: Optional[Dict] = None,
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
_return_future: bool = False,
) -> Optional[ray.ObjectRef]:
"""Schedule and track a task on an actor.
This method will schedule a remote task ``method_name`` on the
``tracked_actor``.
This method accepts two optional callbacks that will be invoked when
their respective events are triggered.
The ``on_result`` callback is triggered when a task resolves successfully.
It should accept two arguments: The actor for which the
task resolved, and the result received from the remote call.
The ``on_error`` callback is triggered when a task fails.
It should accept two arguments: The actor for which the
task threw an error, and the exception.
Args:
tracked_actor: Actor to schedule task on.
method_name: Remote method name to invoke on the actor. If this is
e.g. ``foo``, then ``actor.foo.remote(*args, **kwargs)`` will be
scheduled.
args: Arguments to pass to the task.
kwargs: Keyword arguments to pass to the task.
on_result: Callback to invoke when the task resolves.
on_error: Callback to invoke when the task fails.
_return_future: If True, return the scheduled task's ``ObjectRef`` for
advanced callers. Defaults to False.
Raises:
ValueError: If the ``tracked_actor`` is not managed by this event manager.
Returns:
The scheduled task's ``ObjectRef`` if ``_return_future`` is True,
otherwise ``None``.
"""
args = args or tuple()
kwargs = kwargs or {}
if tracked_actor.actor_id in self._failed_actor_ids:
return
tracked_actor_task = TrackedActorTask(
tracked_actor=tracked_actor, on_result=on_result, on_error=on_error
)
if tracked_actor not in self._live_actors_to_ray_actors_resources:
# Actor is not started, yet
if tracked_actor not in self._pending_actors_to_attrs:
raise ValueError(
f"Tracked actor is not managed by this event manager: "
f"{tracked_actor}"
)
# Cache tasks for future execution
self._pending_actors_to_enqueued_actor_tasks[tracked_actor].append(
(tracked_actor_task, method_name, args, kwargs)
)
else:
res = self._schedule_tracked_actor_task(
tracked_actor_task=tracked_actor_task,
method_name=method_name,
args=args,
kwargs=kwargs,
_return_future=_return_future,
)
if _return_future:
return res[1]
def _schedule_tracked_actor_task(
self,
tracked_actor_task: TrackedActorTask,
method_name: str,
*,
args: Optional[Tuple] = None,
kwargs: Optional[Dict] = None,
_return_future: bool = False,
) -> Union[TrackedActorTask, Tuple[TrackedActorTask, ray.ObjectRef]]:
tracked_actor = tracked_actor_task._tracked_actor
ray_actor, _ = self._live_actors_to_ray_actors_resources[tracked_actor]
try:
remote_fn = getattr(ray_actor, method_name)
except AttributeError as e:
raise AttributeError(
f"Remote function `{method_name}()` does not exist for this actor."
) from e
def on_result(result: Any):
self._actor_task_resolved(
tracked_actor_task=tracked_actor_task, result=result
)
def on_error(exception: Exception):
self._actor_task_failed(
tracked_actor_task=tracked_actor_task, exception=exception
)
future = remote_fn.remote(*args, **kwargs)
self._actor_task_events.track_future(
future=future, on_result=on_result, on_error=on_error
)
self._tracked_actors_to_task_futures[tracked_actor].add(future)
if _return_future:
return tracked_actor_task, future
return tracked_actor_task
def schedule_actor_tasks(
self,
tracked_actors: List[TrackedActor],
method_name: str,
*,
args: Optional[Union[Tuple, List[Tuple]]] = None,
kwargs: Optional[Union[Dict, List[Dict]]] = None,
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
) -> None:
"""Schedule and track tasks on a list of actors.
This method will schedule a remote task ``method_name`` on all
``tracked_actors``.
``args`` and ``kwargs`` can be a single tuple/dict, in which case the same
(keyword) arguments are passed to all actors. If a list is passed instead,
they are mapped to the respective actors. In that case, the list of
(keyword) arguments must be the same length as the list of actors.
This method accepts two optional callbacks that will be invoked when
their respective events are triggered.
The ``on_result`` callback is triggered when a task resolves successfully.
It should accept two arguments: The actor for which the
task resolved, and the result received from the remote call.
The ``on_error`` callback is triggered when a task fails.
It should accept two arguments: The actor for which the
task threw an error, and the exception.
Args:
tracked_actors: List of actors to schedule tasks on.
method_name: Remote actor method to invoke on the actors. If this is
e.g. ``foo``, then ``actor.foo.remote(*args, **kwargs)`` will be
scheduled on all actors.
args: Arguments to pass to the task.
kwargs: Keyword arguments to pass to the task.
on_result: Callback to invoke when the task resolves.
on_error: Callback to invoke when the task fails.
"""
if not isinstance(args, List):
args_list = [args] * len(tracked_actors)
else:
if len(tracked_actors) != len(args):
raise ValueError(
f"Length of args must be the same as tracked_actors "
f"list. Got `len(kwargs)={len(kwargs)}` and "
f"`len(tracked_actors)={len(tracked_actors)}"
)
args_list = args
if not isinstance(kwargs, List):
kwargs_list = [kwargs] * len(tracked_actors)
else:
if len(tracked_actors) != len(kwargs):
raise ValueError(
f"Length of kwargs must be the same as tracked_actors "
f"list. Got `len(args)={len(args)}` and "
f"`len(tracked_actors)={len(tracked_actors)}"
)
kwargs_list = kwargs
for tracked_actor, args, kwargs in zip(tracked_actors, args_list, kwargs_list):
self.schedule_actor_task(
tracked_actor=tracked_actor,
method_name=method_name,
args=args,
kwargs=kwargs,
on_result=on_result,
on_error=on_error,
)
def clear_actor_task_futures(self, tracked_actor: TrackedActor):
"""Discard all actor task futures from a tracked actor."""
futures = self._tracked_actors_to_task_futures.pop(tracked_actor, [])
for future in futures:
self._actor_task_events.discard_future(future)
def _cleanup_actor_futures(self, tracked_actor: TrackedActor):
# Remove all actor task futures
self.clear_actor_task_futures(tracked_actor=tracked_actor)
# Remove all actor state futures
futures = self._tracked_actors_to_state_futures.pop(tracked_actor, [])
for future in futures:
self._actor_state_events.discard_future(future)
def cleanup(self):
for (
actor,
acquired_resources,
) in self._live_actors_to_ray_actors_resources.values():
ray.kill(actor)
self._resource_manager.free_resources(acquired_resources)
for (
resource_request,
pending_actors,
) in self._resource_request_to_pending_actors.items():
for i in range(len(pending_actors)):
self._resource_manager.cancel_resource_request(resource_request)
self._resource_manager.clear()
self.__init__(resource_manager=self._resource_manager)
@@ -0,0 +1,93 @@
from typing import Any, Callable, List, Optional, Tuple
class Barrier:
"""Barrier to collect results and process them in bulk.
A barrier can be used to collect multiple results and process them in bulk once
a certain count or a timeout is reached.
For instance, if ``max_results=N``, the ``on_completion`` callback will be
invoked once :meth:`arrive` has been called ``N`` times.
The completion callback will only be invoked once, even if more results
arrive after completion. The collected results can be resetted
with :meth:`reset`, after which the callback may be invoked again.
The completion callback should expect one argument, which is the barrier
object that completed.
Args:
max_results: Maximum number of results to collect before a call to
:meth:`wait` resolves or the :meth:`on_completion` callback is invoked.
on_completion: Callback to invoke when ``max_results`` results
arrived at the barrier.
"""
def __init__(
self,
max_results: int,
*,
on_completion: Optional[Callable[["Barrier"], None]] = None,
):
self._max_results = max_results
# on_completion callback
self._completed = False
self._on_completion = on_completion
# Collect received results
self._results: List[Tuple[Any]] = []
def arrive(self, *data: Any):
"""Notify barrier that a result successfully arrived.
This will count against the ``max_results`` limit. The received result
will be included in a call to :meth:`get_results`.
Args:
*data: Result data to be cached. Can be obtained via :meth:`get_results`.
"""
if len(data) == 1:
data = data[0]
self._results.append(data)
self._check_completion()
def _check_completion(self):
if self._completed:
# Already fired completion callback
return
if self.num_results >= self._max_results:
# Barrier is complete
self._completed = True
if self._on_completion:
self._on_completion(self)
@property
def completed(self) -> bool:
"""Returns True if the barrier is completed."""
return self._completed
@property
def num_results(self) -> int:
"""Number of received (successful) results."""
return len(self._results)
def get_results(self) -> List[Tuple[Any]]:
"""Return list of received results."""
return self._results
def reset(self) -> None:
"""Reset barrier, removing all received results.
Resetting the barrier will reset the completion status. When ``max_results``
is set and enough new events arrive after resetting, the
:meth:`on_completion` callback will be invoked again.
"""
self._completed = False
self._results = []
@@ -0,0 +1,148 @@
import random
from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, Union
import ray
_ResultCallback = Callable[[Any], None]
_ErrorCallback = Callable[[Exception], None]
class RayEventManager:
"""Event manager for Ray futures.
The event manager can be used to track futures and invoke callbacks when
they resolve.
Futures are tracked with :meth:`track_future`. Future can then be awaited with
:meth:`wait`. When futures successfully resolve, they trigger an optional
``on_result`` callback that can be passed to :meth:`track_future`. If they
fail, they trigger an optional ``on_error`` callback.
Args:
shuffle_futures: If True, futures will be shuffled before awaited. This
will avoid implicit prioritization of futures within Ray.
"""
def __init__(self, shuffle_futures: bool = True):
self._shuffle_futures = shuffle_futures
# Map of futures to callbacks (result, error)
self._tracked_futures: Dict[
ray.ObjectRef, Tuple[Optional[_ResultCallback], Optional[_ErrorCallback]]
] = {}
def track_future(
self,
future: ray.ObjectRef,
on_result: Optional[_ResultCallback] = None,
on_error: Optional[_ErrorCallback] = None,
):
"""Track a single future and invoke callbacks on resolution.
Control has to be yielded to the event manager for the callbacks to
be invoked, either via :meth:`wait` or via :meth:`resolve_future`.
Args:
future: Ray future to await.
on_result: Callback to invoke when the future resolves successfully.
on_error: Callback to invoke when the future fails.
"""
self._tracked_futures[future] = (on_result, on_error)
def track_futures(
self,
futures: Iterable[ray.ObjectRef],
on_result: Optional[_ResultCallback] = None,
on_error: Optional[_ErrorCallback] = None,
):
"""Track multiple futures and invoke callbacks on resolution.
Control has to be yielded to the event manager for the callbacks to
be invoked, either via :meth:`wait` or via :meth:`resolve_future`.
Args:
futures: Ray futures to await.
on_result: Callback to invoke when the future resolves successfully.
on_error: Callback to invoke when the future fails.
"""
for future in futures:
self.track_future(future, on_result=on_result, on_error=on_error)
def discard_future(self, future: ray.ObjectRef):
"""Remove future from tracking.
The future will not be awaited anymore, and it will not trigger any callbacks.
Args:
future: Ray futures to discard.
"""
self._tracked_futures.pop(future, None)
def get_futures(self) -> Set[ray.ObjectRef]:
"""Get futures tracked by the event manager."""
return set(self._tracked_futures)
@property
def num_futures(self) -> int:
return len(self._tracked_futures)
def resolve_future(self, future: ray.ObjectRef):
"""Resolve a single future.
This method will block until the future is available. It will then
trigger the callback associated to the future and the event (success
or error), if specified.
Args:
future: Ray future to resolve.
"""
try:
on_result, on_error = self._tracked_futures.pop(future)
except KeyError as e:
raise ValueError(
f"Future {future} is not tracked by this RayEventManager"
) from e
try:
result = ray.get(future)
except Exception as e:
if on_error:
on_error(e)
else:
raise e
else:
if on_result:
on_result(result)
def wait(
self,
timeout: Optional[Union[float, int]] = None,
num_results: Optional[int] = 1,
):
"""Wait up to ``timeout`` seconds for ``num_results`` futures to resolve.
If ``timeout=None``, this method will block until all `num_results`` futures
resolve. If ``num_results=None``, this method will await all tracked futures.
For every future that resolves, the respective associated callbacks will be
invoked.
Args:
timeout: Timeout in second to wait for futures to resolve.
num_results: Number of futures to await. If ``None``, will wait for
all tracked futures to resolve.
"""
futures = list(self.get_futures())
if self._shuffle_futures:
random.shuffle(futures)
num_results = num_results or len(futures)
ready, _ = ray.wait(list(futures), timeout=timeout, num_returns=num_results)
for future in ready:
self.resolve_future(future)
@@ -0,0 +1,62 @@
from typing import Callable, Optional
class TrackedActor:
"""Actor tracked by an actor manager.
This object is used to reference a Ray actor on an actor manager
Existence of this object does not mean that the Ray actor has already been started.
Actor state can be inquired from the actor manager tracking the Ray actor.
Note:
Objects of this class are returned by the :class:`RayActorManager`.
This class should not be instantiated manually.
Attributes:
actor_id: ID for identification of the actor within the actor manager. This
ID is not related to the Ray actor ID.
"""
def __init__(
self,
actor_id: int,
on_start: Optional[Callable[["TrackedActor"], None]] = None,
on_stop: Optional[Callable[["TrackedActor"], None]] = None,
on_error: Optional[Callable[["TrackedActor", Exception], None]] = None,
):
"""Initialize the tracked actor.
Args:
actor_id: ID for identification of the actor within the actor manager.
on_start: Callback to invoke when the actor started.
on_stop: Callback to invoke when the actor stopped.
on_error: Callback to invoke when the actor failed.
"""
self.actor_id = actor_id
self._on_start = on_start
self._on_stop = on_stop
self._on_error = on_error
def set_on_start(self, on_start: Optional[Callable[["TrackedActor"], None]]):
self._on_start = on_start
def set_on_stop(self, on_stop: Optional[Callable[["TrackedActor"], None]]):
self._on_stop = on_stop
def set_on_error(
self, on_error: Optional[Callable[["TrackedActor", Exception], None]]
):
self._on_error = on_error
def __repr__(self):
return f"<TrackedActor {self.actor_id}>"
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return self.actor_id == other.actor_id
def __hash__(self):
return hash(self.actor_id)
@@ -0,0 +1,42 @@
from typing import Any, Callable, Optional
from ray.air.execution._internal.tracked_actor import TrackedActor
class TrackedActorTask:
"""Actor task tracked by a Ray event manager.
This container class is used to define callbacks to be invoked when
the task resolves, errors, or times out.
Note:
Objects of this class are returned by the :class:`RayActorManager`.
This class should not be instantiated manually.
Args:
tracked_actor: Tracked actor object this task is scheduled on.
on_result: Callback to invoke when the task resolves.
on_error: Callback to invoke when the task fails.
Example:
.. code-block:: python
tracked_futures = actor_manager.schedule_actor_tasks(
actor_manager.live_actors,
"foo",
on_result=lambda actor, result: print(result)
)
"""
def __init__(
self,
tracked_actor: TrackedActor,
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
):
self._tracked_actor = tracked_actor
self._on_result = on_result
self._on_error = on_error
@@ -0,0 +1,12 @@
from ray.air.execution.resources.fixed import FixedResourceManager
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
from ray.air.execution.resources.resource_manager import ResourceManager
__all__ = [
"ResourceRequest",
"AcquiredResources",
"ResourceManager",
"FixedResourceManager",
"PlacementGroupResourceManager",
]
+147
View File
@@ -0,0 +1,147 @@
from dataclasses import dataclass
from typing import Dict, List, Optional
import ray
from ray import SCRIPT_MODE
from ray.air.execution.resources.request import (
AcquiredResources,
RemoteRayEntity,
ResourceRequest,
)
from ray.air.execution.resources.resource_manager import ResourceManager
from ray.util.annotations import DeveloperAPI
# Avoid numerical errors by multiplying and subtracting with this number.
# Compare: 0.99 - 0.33 = 0.65999... vs (0.99 * 1000 - 0.33 * 1000) / 1000 = 0.66
_DIGITS = 100000
@DeveloperAPI
@dataclass
class FixedAcquiredResources(AcquiredResources):
bundles: List[Dict[str, float]]
def _annotate_remote_entity(
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
) -> RemoteRayEntity:
bundle = bundle.copy()
num_cpus = bundle.pop("CPU", 0)
num_gpus = bundle.pop("GPU", 0)
memory = bundle.pop("memory", 0.0)
return entity.options(
num_cpus=num_cpus,
num_gpus=num_gpus,
memory=memory,
resources=bundle,
)
@DeveloperAPI
class FixedResourceManager(ResourceManager):
"""Fixed budget based resource manager.
This resource manager keeps track of a fixed set of resources. When resources
are acquired, they are subtracted from the budget. When resources are freed,
they are added back to the budget.
The resource manager still requires resources to be requested before they become
available. However, because the resource requests are virtual, this will not
trigger autoscaling.
Additionally, resources are not reserved on request, only on acquisition. Thus,
acquiring a resource can change the availability of other requests. Note that
this behavior may be changed in future implementations.
The fixed resource manager does not support placement strategies. Using
``STRICT_SPREAD`` will result in an error. ``STRICT_PACK`` will succeed only
within a placement group bundle. All other placement group arguments will be
ignored.
Args:
total_resources: Budget of resources to manage. Defaults to all available
resources in the current task or all cluster resources (if outside a task).
"""
_resource_cls: AcquiredResources = FixedAcquiredResources
def __init__(self, total_resources: Optional[Dict[str, float]] = None):
rtc = ray.get_runtime_context()
if not total_resources:
if rtc.worker.mode in {None, SCRIPT_MODE}:
total_resources = ray.cluster_resources()
else:
total_resources = rtc.get_assigned_resources()
# If we are in a placement group, all of our resources will be in a bundle
# and thus fulfill requirements of STRICT_PACK - but only if child tasks
# are captured by the pg.
self._allow_strict_pack = (
ray.util.get_current_placement_group() is not None
and rtc.should_capture_child_tasks_in_placement_group
)
self._total_resources = total_resources
self._requested_resources = []
self._used_resources = []
@property
def _available_resources(self) -> Dict[str, float]:
available_resources = self._total_resources.copy()
for used_resources in self._used_resources:
all_resources = used_resources.required_resources
for k, v in all_resources.items():
available_resources[k] = (
available_resources[k] * _DIGITS - v * _DIGITS
) / _DIGITS
return available_resources
def request_resources(self, resource_request: ResourceRequest):
if resource_request.strategy == "STRICT_SPREAD" or (
not self._allow_strict_pack and resource_request.strategy == "STRICT_PACK"
):
raise RuntimeError(
f"Requested a resource with placement strategy "
f"{resource_request.strategy}, but this cannot be fulfilled by a "
f"FixedResourceManager. In a nested setting, please set the inner "
f"placement strategy to be less restrictive (i.e. no STRICT_ strategy)."
)
self._requested_resources.append(resource_request)
def cancel_resource_request(self, resource_request: ResourceRequest):
self._requested_resources.remove(resource_request)
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
if resource_request not in self._requested_resources:
return False
available_resources = self._available_resources
all_resources = resource_request.required_resources
for k, v in all_resources.items():
if available_resources.get(k, 0.0) < v:
return False
return True
def acquire_resources(
self, resource_request: ResourceRequest
) -> Optional[AcquiredResources]:
if not self.has_resources_ready(resource_request):
return None
self._used_resources.append(resource_request)
return self._resource_cls(
bundles=resource_request.bundles, resource_request=resource_request
)
def free_resources(self, acquired_resource: AcquiredResources):
resources = acquired_resource.resource_request
self._used_resources.remove(resources)
def clear(self):
# Reset internal state
self._requested_resources = []
self._used_resources = []
@@ -0,0 +1,214 @@
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, List, Optional, Set
import ray
from ray.air.execution.resources.request import (
AcquiredResources,
RemoteRayEntity,
ResourceRequest,
)
from ray.air.execution.resources.resource_manager import ResourceManager
from ray.util.annotations import DeveloperAPI
from ray.util.placement_group import PlacementGroup, remove_placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
@DeveloperAPI
@dataclass
class PlacementGroupAcquiredResources(AcquiredResources):
placement_group: PlacementGroup
def _annotate_remote_entity(
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
) -> RemoteRayEntity:
bundle = bundle.copy()
num_cpus = bundle.pop("CPU", 0)
num_gpus = bundle.pop("GPU", 0)
memory = bundle.pop("memory", 0.0)
return entity.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=self.placement_group,
placement_group_bundle_index=bundle_index,
placement_group_capture_child_tasks=True,
),
num_cpus=num_cpus,
num_gpus=num_gpus,
memory=memory,
resources=bundle,
)
@DeveloperAPI
class PlacementGroupResourceManager(ResourceManager):
"""Resource manager using placement groups as the resource backend.
This manager will use placement groups to fulfill resource requests. Requesting
a resource will schedule the placement group. Acquiring a resource will
return a ``PlacementGroupAcquiredResources`` that can be used to schedule
Ray tasks and actors on the placement group. Freeing an acquired resource
will destroy the associated placement group.
Ray core does not emit events when resources are available. Instead, the
scheduling state has to be periodically updated.
Per default, placement group scheduling state is refreshed every time when
resource state is inquired, but not more often than once every ``update_interval_s``
seconds. Alternatively, staging futures can be retrieved (and awaited) with
``get_resource_futures()`` and state update can be force with ``update_state()``.
Args:
update_interval_s: Minimum interval in seconds between updating scheduling
state of placement groups.
"""
_resource_cls: AcquiredResources = PlacementGroupAcquiredResources
def __init__(self, update_interval_s: float = 0.1):
# Internally, the placement group lifecycle is like this:
# - Resources are requested with ``request_resources()``
# - A placement group is scheduled ("staged")
# - A ``PlacementGroup.ready()`` future is scheduled ("staging future")
# - We update the scheduling state when we need to
# (e.g. when ``has_resources_ready()`` is called)
# - When staging futures resolve, a placement group is moved from "staging"
# to "ready"
# - When a resource request is canceled, we remove a placement group from
# "staging". If there are not staged placement groups
# (because they are already "ready"), we remove one from "ready" instead.
# - When a resource is acquired, the pg is removed from "ready" and moved
# to "acquired"
# - When a resource is freed, the pg is removed from "acquired" and destroyed
# Mapping of placement group to request
self._pg_to_request: Dict[PlacementGroup, ResourceRequest] = {}
# PGs that are staged but not "ready", yet (i.e. not CREATED)
self._request_to_staged_pgs: Dict[
ResourceRequest, Set[PlacementGroup]
] = defaultdict(set)
# PGs that are CREATED and can be used by tasks and actors
self._request_to_ready_pgs: Dict[
ResourceRequest, Set[PlacementGroup]
] = defaultdict(set)
# Staging futures used to update internal state.
# We keep a double mapping here for better lookup efficiency.
self._staging_future_to_pg: Dict[ray.ObjectRef, PlacementGroup] = dict()
self._pg_to_staging_future: Dict[PlacementGroup, ray.ObjectRef] = dict()
# Set of acquired PGs. We keep track of these here to make sure we
# only free PGs that this manager managed.
self._acquired_pgs: Set[PlacementGroup] = set()
# Minimum time between updates of the internal state
self.update_interval_s = update_interval_s
self._last_update = time.monotonic() - self.update_interval_s - 1
def get_resource_futures(self) -> List[ray.ObjectRef]:
return list(self._staging_future_to_pg.keys())
def _maybe_update_state(self):
now = time.monotonic()
if now > self._last_update + self.update_interval_s:
self.update_state()
def update_state(self):
ready, not_ready = ray.wait(
list(self._staging_future_to_pg.keys()),
num_returns=len(self._staging_future_to_pg),
timeout=0,
)
for future in ready:
# Remove staging future
pg = self._staging_future_to_pg.pop(future)
self._pg_to_staging_future.pop(pg)
# Fetch resource request
request = self._pg_to_request[pg]
# Remove from staging, add to ready
self._request_to_staged_pgs[request].remove(pg)
self._request_to_ready_pgs[request].add(pg)
self._last_update = time.monotonic()
def request_resources(self, resource_request: ResourceRequest):
pg = resource_request.to_placement_group()
self._pg_to_request[pg] = resource_request
self._request_to_staged_pgs[resource_request].add(pg)
future = pg.ready()
self._staging_future_to_pg[future] = pg
self._pg_to_staging_future[pg] = future
def cancel_resource_request(self, resource_request: ResourceRequest):
if self._request_to_staged_pgs[resource_request]:
pg = self._request_to_staged_pgs[resource_request].pop()
# PG was staging
future = self._pg_to_staging_future.pop(pg)
self._staging_future_to_pg.pop(future)
# Cancel the pg.ready task.
# Otherwise, it will be pending node assignment forever.
ray.cancel(future)
else:
# PG might be ready
pg = self._request_to_ready_pgs[resource_request].pop()
if not pg:
raise RuntimeError(
"Cannot cancel resource request: No placement group was "
f"staged or is ready. Make sure to not cancel more resource "
f"requests than you've created. Request: {resource_request}"
)
self._pg_to_request.pop(pg)
ray.util.remove_placement_group(pg)
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
if not bool(len(self._request_to_ready_pgs[resource_request])):
# Only update state if needed
self._maybe_update_state()
return bool(len(self._request_to_ready_pgs[resource_request]))
def acquire_resources(
self, resource_request: ResourceRequest
) -> Optional[PlacementGroupAcquiredResources]:
if not self.has_resources_ready(resource_request):
return None
pg = self._request_to_ready_pgs[resource_request].pop()
self._acquired_pgs.add(pg)
return self._resource_cls(placement_group=pg, resource_request=resource_request)
def free_resources(self, acquired_resource: PlacementGroupAcquiredResources):
pg = acquired_resource.placement_group
self._acquired_pgs.remove(pg)
remove_placement_group(pg)
self._pg_to_request.pop(pg)
def clear(self):
if not ray.is_initialized():
return
for staged_pgs in self._request_to_staged_pgs.values():
for staged_pg in staged_pgs:
remove_placement_group(staged_pg)
for ready_pgs in self._request_to_ready_pgs.values():
for ready_pg in ready_pgs:
remove_placement_group(ready_pg)
for acquired_pg in self._acquired_pgs:
remove_placement_group(acquired_pg)
# Reset internal state
self.__init__(update_interval_s=self.update_interval_s)
def __del__(self):
self.clear()
@@ -0,0 +1,259 @@
import abc
import json
from copy import deepcopy
from dataclasses import dataclass
from inspect import signature
from typing import Dict, List, Union
import ray
from ray.util import placement_group
from ray.util.annotations import DeveloperAPI
RemoteRayEntity = Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass]
def _sum_bundles(bundles: List[Dict[str, float]]) -> Dict[str, float]:
"""Sum all resources in a list of resource bundles.
Args:
bundles: List of resource bundles.
Returns:
Dict containing all resources summed up.
"""
resources = {}
for bundle in bundles:
for k, v in bundle.items():
resources[k] = resources.get(k, 0) + v
return resources
@DeveloperAPI
class ResourceRequest:
"""Request for resources.
This class is used to define a resource request. A resource request comprises one
or more bundles of resources and instructions on the scheduling behavior.
The resource request can be submitted to a resource manager, which will
schedule the resources. Depending on the resource backend, this may instruct
Ray to scale up (autoscaling).
Resource requests are compatible with the most fine-grained low-level resource
backend, which are Ray placement groups.
Args:
bundles: A list of bundles which represent the resources requirements.
E.g. ``[{"CPU": 1, "GPU": 1}]``.
strategy: The scheduling strategy to acquire the bundles.
- "PACK": Packs Bundles into as few nodes as possible.
- "SPREAD": Places Bundles across distinct nodes as even as possible.
- "STRICT_PACK": Packs Bundles into one node. The group is
not allowed to span multiple nodes.
- "STRICT_SPREAD": Packs Bundles across distinct nodes.
*args: Passed to the call of ``placement_group()``, if applicable.
**kwargs: Passed to the call of ``placement_group()``, if applicable.
"""
def __init__(
self,
bundles: List[Dict[str, Union[int, float]]],
strategy: str = "PACK",
*args,
**kwargs,
):
if not bundles:
raise ValueError("Cannot initialize a ResourceRequest with zero bundles.")
# Remove empty resource keys
self._bundles = [
{k: float(v) for k, v in bundle.items() if v != 0} for bundle in bundles
]
# Check if the head bundle is empty (no resources defined or all resources
# are 0 (and thus removed in the previous step)
if not self._bundles[0]:
# This is when the head bundle doesn't need resources.
self._head_bundle_is_empty = True
self._bundles.pop(0)
if not self._bundles:
raise ValueError(
"Cannot initialize a ResourceRequest with an empty head "
"and zero worker bundles."
)
else:
self._head_bundle_is_empty = False
self._strategy = strategy
self._args = args
self._kwargs = kwargs
self._hash = None
self._bound = None
self._bind()
@property
def head_bundle_is_empty(self):
"""Returns True if head bundle is empty while child bundles
need resources.
This is considered an internal API within Tune.
"""
return self._head_bundle_is_empty
@property
@DeveloperAPI
def head_cpus(self) -> float:
"""Returns the number of cpus in the head bundle."""
return 0.0 if self._head_bundle_is_empty else self._bundles[0].get("CPU", 0.0)
@property
@DeveloperAPI
def bundles(self) -> List[Dict[str, float]]:
"""Returns a deep copy of resource bundles"""
return deepcopy(self._bundles)
@property
def required_resources(self) -> Dict[str, float]:
"""Returns a dict containing the sums of all resources"""
return _sum_bundles(self._bundles)
@property
@DeveloperAPI
def strategy(self) -> str:
"""Returns the placement strategy"""
return self._strategy
def _bind(self):
"""Bind the args and kwargs to the `placement_group()` signature.
We bind the args and kwargs, so we can compare equality of two resource
requests. The main reason for this is that the `placement_group()` API
can evolve independently from the ResourceRequest API (e.g. adding new
arguments). Then, `ResourceRequest(bundles, strategy, arg=arg)` should
be the same as `ResourceRequest(bundles, strategy, arg)`.
"""
sig = signature(placement_group)
try:
self._bound = sig.bind(
self._bundles, self._strategy, *self._args, **self._kwargs
)
except Exception as exc:
raise RuntimeError(
"Invalid definition for resource request. Please check "
"that you passed valid arguments to the ResourceRequest "
"object."
) from exc
def to_placement_group(self):
return placement_group(*self._bound.args, **self._bound.kwargs)
def __eq__(self, other: "ResourceRequest"):
return (
isinstance(other, ResourceRequest)
and self._bound == other._bound
and self.head_bundle_is_empty == other.head_bundle_is_empty
)
def __hash__(self):
if not self._hash:
# Cache hash
self._hash = hash(
json.dumps(
{"args": self._bound.args, "kwargs": self._bound.kwargs},
sort_keys=True,
indent=0,
ensure_ascii=True,
)
)
return self._hash
def __getstate__(self):
state = self.__dict__.copy()
state.pop("_hash", None)
state.pop("_bound", None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
self._hash = None
self._bound = None
self._bind()
def __repr__(self) -> str:
return (
f"<ResourceRequest (_bound={self._bound}, "
f"head_bundle_is_empty={self.head_bundle_is_empty})>"
)
@DeveloperAPI
@dataclass
class AcquiredResources(abc.ABC):
"""Base class for resources that have been acquired.
Acquired resources can be associated to Ray objects, which can then be
scheduled using these resources.
Internally this can point e.g. to a placement group, a placement
group bundle index, or just raw resources.
The main API is the `annotate_remote_entities` method. This will associate
remote Ray objects (tasks and actors) with the acquired resources by setting
the Ray remote options to use the acquired resources.
"""
resource_request: ResourceRequest
def annotate_remote_entities(
self, entities: List[RemoteRayEntity]
) -> List[Union[RemoteRayEntity]]:
"""Return remote ray entities (tasks/actors) to use the acquired resources.
The first entity will be associated with the first bundle, the second
entity will be associated with the second bundle, etc.
Args:
entities: Remote Ray entities to annotate with the acquired resources.
Returns:
The list of annotated remote Ray entities.
"""
bundles = self.resource_request.bundles
# Also count the empty head bundle as a bundle
num_bundles = len(bundles) + int(self.resource_request.head_bundle_is_empty)
if len(entities) > num_bundles:
raise RuntimeError(
f"The number of callables to annotate ({len(entities)}) cannot "
f"exceed the number of available bundles ({num_bundles})."
)
annotated = []
if self.resource_request.head_bundle_is_empty:
# The empty head bundle is place on the first bundle index with empty
# resources.
annotated.append(
self._annotate_remote_entity(entities[0], {}, bundle_index=0)
)
# Shift the remaining entities
entities = entities[1:]
for i, (entity, bundle) in enumerate(zip(entities, bundles)):
annotated.append(
self._annotate_remote_entity(entity, bundle, bundle_index=i)
)
return annotated
def _annotate_remote_entity(
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
) -> RemoteRayEntity:
raise NotImplementedError
@@ -0,0 +1,155 @@
import abc
from typing import List, Optional
import ray
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class ResourceManager(abc.ABC):
"""Resource manager interface.
A resource manager can be used to request resources from a Ray cluster and
allocate them to remote Ray tasks or actors.
Resources have to be requested before they can be acquired.
Resources managed by the resource manager can be in three states:
1. "Requested": The resources have been requested but are not yet available to
schedule remote Ray objects. The resource request may trigger autoscaling,
and can be cancelled if no longer needed.
2. "Ready": The requested resources are now available to schedule remote Ray
objects. They can be acquired and subsequently used remote Ray objects.
The resource request can still be cancelled if no longer needed.
3. "Acquired": The resources have been acquired by a caller to use for scheduling
remote Ray objects. Note that it is the responsibility of the caller to
schedule the Ray objects with these resources.
The associated resource request has been completed and can no longer be
cancelled. The acquired resources can be freed by the resource manager when
they are no longer used.
The flow is as follows:
.. code-block:: python
# Create resource manager
resource_manager = ResourceManager()
# Create resource request
resource_request = ResourceRequest([{"CPU": 4}])
# Pass to resource manager
resource_manager.request_resources(resource_request)
# Wait until ready
while not resource_manager.has_resources_ready(resource_request):
time.sleep(1)
# Once ready, acquire resources
acquired_resource = resource_manager.acquire_resources(resource_request)
# Bind to remote task or actor
annotated_remote_fn = acquired_resource.annotate_remote_entities(
[remote_fn])
# Run remote function. This will use the acquired resources
ray.get(annotated_remote_fn.remote())
# After using the resources, free
resource_manager.free_resources(annotated_resources)
"""
def request_resources(self, resource_request: ResourceRequest):
"""Request resources.
Depending on the backend, resources can trigger autoscaling. Requested
resources can be ready or not ready. Once they are "ready", they can
be acquired and used by remote Ray objects.
Resource requests can be cancelled anytime using ``cancel_resource_request()``.
Once acquired, the resource request is removed. Acquired resources can be
freed with ``free_resources()``.
"""
raise NotImplementedError
def cancel_resource_request(self, resource_request: ResourceRequest):
"""Cancel resource request.
Resource requests can be cancelled anytime before a resource is acquired.
Acquiring a resource will remove the associated resource request.
Acquired resources can be freed with ``free_resources()``.
"""
raise NotImplementedError
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
"""Returns True if resources for the given request are ready to be acquired."""
raise NotImplementedError
def acquire_resources(
self, resource_request: ResourceRequest
) -> Optional[AcquiredResources]:
"""Acquire resources. Returns None if resources are not ready to be acquired.
Acquiring resources will remove the associated resource request.
Acquired resources can be returned with ``free_resources()``.
"""
raise NotImplementedError
def free_resources(self, acquired_resource: AcquiredResources):
"""Free acquired resources from usage and return them to the resource manager.
Freeing resources will return the resources to the manager, but there are
no guarantees about the tasks and actors scheduled on the resources. The caller
should make sure that any references to tasks or actors scheduled on the
resources have been removed before calling ``free_resources()``.
"""
raise NotImplementedError
def get_resource_futures(self) -> List[ray.ObjectRef]:
"""Return futures for resources to await.
Depending on the backend, we use resource futures to determine availability
of resources (e.g. placement groups) or resolution of requests.
In this case, the futures can be awaited externally by the caller.
When a resource future resolved, the caller may call ``update_state()``
to force the resource manager to update its internal state immediately.
"""
return []
def update_state(self):
"""Update internal state of the resource manager.
The resource manager may have internal state that needs periodic updating.
For instance, depending on the backend, resource futures can be awaited
externally (with ``get_resource_futures()``).
If such a future resolved, the caller can instruct the resource
manager to update its internal state immediately.
"""
pass
def clear(self):
"""Reset internal state and clear all resources.
Calling this method will reset the resource manager to its initialization state.
All resources will be removed.
Clearing the state will remove tracked resources from the manager, but there are
no guarantees about the tasks and actors scheduled on the resources. The caller
should make sure that any references to tasks or actors scheduled on the
resources have been removed before calling ``clear()``.
"""
raise NotImplementedError
def __reduce__(self):
"""We disallow serialization.
Shared resource managers should live on an actor.
"""
raise ValueError(
f"Resource managers cannot be serialized. Resource manager: {str(self)}"
)