Files
2026-07-13 13:17:40 +08:00

215 lines
8.3 KiB
Python

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()