chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,71 @@
import enum
import logging
import os
from typing import TYPE_CHECKING
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
from .base_cluster_autoscaler import ClusterAutoscaler
from .default_autoscaling_coordinator import (
DefaultAutoscalingCoordinator,
get_or_create_autoscaling_coordinator,
)
from .default_cluster_autoscaler_v2 import DefaultClusterAutoscalerV2
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor_state import Topology
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
CLUSTER_AUTOSCALER_ENV_KEY = "RAY_DATA_CLUSTER_AUTOSCALER"
DEFAULT_CLUSTER_AUTOSCALER_VERSION = "V2"
class ClusterAutoscalerVersion(str, enum.Enum):
V2 = "V2"
def create_cluster_autoscaler(
topology: "Topology",
resource_manager: "ResourceManager",
data_context: "DataContext",
*,
execution_id: str,
) -> ClusterAutoscaler:
resource_limits = data_context.execution_options.resource_limits
label_selector = data_context.execution_options.label_selector
cluster_autoscaler_version = os.environ.get(
CLUSTER_AUTOSCALER_ENV_KEY, DEFAULT_CLUSTER_AUTOSCALER_VERSION
)
logger.debug(f"Using cluster autoscaler version: {cluster_autoscaler_version!r}")
if cluster_autoscaler_version == ClusterAutoscalerVersion.V2:
return DefaultClusterAutoscalerV2(
resource_manager,
execution_id=execution_id,
resource_limits=resource_limits,
label_selector=label_selector,
)
else:
valid_values = [version.value for version in ClusterAutoscalerVersion]
raise ValueError(
f"Cluster autoscaler version of {cluster_autoscaler_version} isn't a valid "
f"option. Valid options are: {valid_values}."
)
__all__ = [
"ClusterAutoscaler",
# Objects related to the `AutoscalingCoordinator`.
"AutoscalingCoordinator",
"DefaultAutoscalingCoordinator",
"get_or_create_autoscaling_coordinator",
"ResourceDict",
"ResourceRequestPriority",
]
@@ -0,0 +1,58 @@
import abc
from enum import Enum
from typing import Dict, List, Optional
ResourceDict = Dict[str, float]
class ResourceRequestPriority(Enum):
"""Priority of a resource request."""
LOW = -10
MEDIUM = 0
HIGH = 10
class AutoscalingCoordinator(abc.ABC):
@abc.abstractmethod
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Request cluster resources.
The requested resources should represent the full set of resources needed,
not just the incremental amount.
Args:
resources: The requested resources. This should match the format accepted
by `ray.autoscaler.sdk.request_resources`.
expire_after_s: Time in seconds after which this request will expire.
The requester is responsible for periodically sending new requests
to avoid the request being purged.
request_remaining: If true, after allocating requested resources to each
requester, remaining resources will also be allocated to this requester.
priority: The priority of the request. Higher value means higher priority.
label_selectors: Optional per-bundle label selectors, one per entry in
``resources``. Forwarded to the autoscaler as
``bundle_label_selectors``.
"""
...
@abc.abstractmethod
def cancel_request(self) -> None:
"""Cancel the resource request from the requester."""
...
@abc.abstractmethod
def get_allocated_resources(self) -> List[ResourceDict]:
"""Get the allocated resources for the requester.
Returns:
A list of dictionaries representing the allocated resources bundles.
"""
...
@@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces.execution_options import (
ExecutionResources,
)
@DeveloperAPI
class ClusterAutoscaler(ABC):
"""Abstract interface for Ray Data cluster autoscaler."""
@abstractmethod
def try_trigger_scaling(self):
"""Try trigger autoscaling.
This method will be called each time when StreamingExecutor makes
a scheduling decision. A subclass should override this method to
handle the autoscaling of the cluster.
"""
...
@abstractmethod
def on_executor_shutdown(self):
"""Callback when the StreamingExecutor is shutting down."""
...
@abstractmethod
def get_total_resources(self) -> "ExecutionResources":
"""Get the total resources that are available to this data execution."""
...
@@ -0,0 +1,577 @@
import copy
import functools
import logging
import threading
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
import ray
import ray.exceptions
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
from ray._common.utils import env_bool
from ray.data._internal.execution.util import memory_string
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
logger = logging.getLogger(__name__)
HEAD_NODE_RESOURCE_LABEL = "node:__internal_head__"
_RESOURCE_LOG_KEYS = ("CPU", "GPU", "memory", "object_store_memory")
_RESOURCE_LOG_MEMORY_KEYS = {"memory", "object_store_memory"}
# Label key the cluster autoscaler uses to bucket nodes by subcluster.
# Hardcoded so all components agree without per-Dataset configuration.
SUBCLUSTER_LABEL_KEY = "ray-subcluster"
# Sentinel for "no subcluster" — used as both a node-label fallback and
# the bucket key for unlabeled nodes in ``_cluster_node_resources``.
DEFAULT_SUBCLUSTER: Optional[str] = None
RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK = env_bool(
"RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK", True
)
def _format_resource_value_for_log(resource_name: str, value: float) -> str:
"""Format a numerical resource value to a human-readable string.
Args:
resource_name: The resource name.
value: The resource value.
Returns:
A human-readable string.
"""
if resource_name in _RESOURCE_LOG_MEMORY_KEYS:
return memory_string(value)
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def _format_resource_bundle_for_log(bundle: ResourceDict) -> str:
"""Format a resource bundle to a human-readable string.
Drops custom resource keys (e.g. ``anyscale/...``, ``node:...``) and
zero-valued resources, keeping only the standard keys in ``_RESOURCE_LOG_KEYS``.
Args:
bundle: The resource bundle to format.
Returns:
A human-readable string, e.g. ``"{CPU: 8, memory: 32.0GiB}"``.
Example:
>>> from ray.data._internal.util import GiB
>>> _format_resource_bundle_for_log({"CPU": 8, "GPU": 0, "memory": 32 * GiB})
'{CPU: 8, memory: 32.0GiB}'
"""
resources = []
for resource_name in _RESOURCE_LOG_KEYS:
value = bundle.get(resource_name, 0)
if value == 0:
continue
resources.append(
f"{resource_name}: {_format_resource_value_for_log(resource_name, value)}"
)
return "{" + ", ".join(resources) + "}"
def _format_resources_for_log(resources: List[ResourceDict]) -> str:
"""Format and aggregate resource bundles for logging.
Bundles that format to the same string (after dropping custom/zero-valued
resources) are collapsed into a single ``N x {...}`` entry.
Args:
resources: The resource bundles to format.
Returns:
A human-readable string, e.g. ``"[2 x {CPU: 1}, 1 x {GPU: 1}]"``.
Example:
>>> _format_resources_for_log([{"CPU": 1}, {"CPU": 1}, {"GPU": 1}])
'[2 x {CPU: 1}, 1 x {GPU: 1}]'
"""
bundle_counts: Dict[str, int] = {}
for resource in resources:
bundle = _format_resource_bundle_for_log(resource)
if bundle == "{}":
continue
bundle_counts[bundle] = bundle_counts.get(bundle, 0) + 1
return (
"["
+ ", ".join(f"{count} x {bundle}" for bundle, count in bundle_counts.items())
+ "]"
)
@dataclass
class OngoingRequest:
"""Represents an ongoing resource request from a requester."""
# The time when the request was first received.
first_request_time: float
# Requested resources.
requested_resources: List[ResourceDict]
# The expiration time of the request.
expiration_time: float
# If true, after allocating requested resources to each requester,
# remaining resources will also be allocated to this requester.
request_remaining: bool
# The priority of the request, higher value means higher priority.
priority: int
# Resources that are already allocated to the requester.
allocated_resources: List[ResourceDict]
# Per-bundle label selectors, parallel to ``requested_resources``.
# Empty dicts mean no label constraint on that bundle. Required to have
# the same length as ``requested_resources``.
requested_label_selectors: List[Dict[str, str]]
def __lt__(self, other):
"""Used to sort requests when allocating resources.
Higher priority first, then earlier first_request_time first.
"""
if self.priority != other.priority:
return self.priority > other.priority
return self.first_request_time < other.first_request_time
class DefaultAutoscalingCoordinator(AutoscalingCoordinator):
"""Non-blocking client-side proxy for the _AutoscalingCoordinatorActor.
Not thread-safe; all methods must be called from a single thread.
Create one instance per requester. Multiple instances sharing the same
``requester_id`` will have diverging caches and break the FIFO ordering
guarantee that ``request_resources`` and ``get_allocated_resources`` rely on.
"""
def __init__(
self,
requester_id: str,
autoscaling_coordinator_actor=None, # For testing only: injects an actor instead of using the shared named singleton.
subcluster_selector: Optional[Dict[str, str]] = None,
):
self._requester_id = requester_id
# Label selector keyed by ``SUBCLUSTER_LABEL_KEY`` pinning this
# requester to a single subcluster.
self._subcluster_selector = subcluster_selector
self._cached_allocated_resources: List[ResourceDict] = []
# In-flight get_allocated_resources ref, or None if no request is pending.
self._pending_allocated_resources: Optional[ray.ObjectRef] = None
if autoscaling_coordinator_actor is not None:
# Bypass the cached_property by injecting the actor directly.
# Used in tests to avoid the shared named actor.
self.__dict__["_autoscaling_coordinator"] = autoscaling_coordinator_actor
@functools.cached_property
def _autoscaling_coordinator(self):
# Lazy: avoids creating the actor in __init__.
return get_or_create_autoscaling_coordinator()
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Fire-and-forget: submit a resource request to the coordinator actor.
Actor-side errors are not surfaced to the caller.
"""
self._autoscaling_coordinator.request_resources.remote(
requester_id=self._requester_id,
resources=resources,
expire_after_s=expire_after_s,
request_remaining=request_remaining,
priority=priority,
label_selectors=label_selectors,
subcluster_selector=self._subcluster_selector,
)
def cancel_request(self) -> None:
"""Fire-and-forget: cancel a resource request on the coordinator actor.
Also clears client-side state (pending ref and cached allocation) so
a subsequent ``get_allocated_resources`` call returns a fresh result
rather than stale data from a prior pipeline run.
"""
self._pending_allocated_resources = None
self._cached_allocated_resources = []
self._autoscaling_coordinator.cancel_request.remote(self._requester_id)
def get_allocated_resources(self) -> List[ResourceDict]:
"""Return allocated resources without blocking.
Submits an async RPC and immediately returns the last cached result.
The cache is updated the next time the pending RPC completes.
Because the actor processes calls in FIFO order, the result always
reflects state after all previously submitted ``request_resources`` calls
to the same actor.
On actor errors, returns the cached value and logs a warning; never raises.
"""
ref = self._pending_allocated_resources
if ref is not None:
ready, _ = ray.wait([ref], timeout=0)
if ready:
self._pending_allocated_resources = None
try:
self._cached_allocated_resources = ray.get(ref, timeout=0)
except ray.exceptions.RayError:
logger.warning(
f"Failed to get allocated resources for {self._requester_id};"
" falling back to the cached value."
" If this persists, file a GitHub issue.",
exc_info=RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK,
)
# Submit a new request if none is currently in-flight
# (first call, or the previous request completed or errored).
if self._pending_allocated_resources is None:
self._pending_allocated_resources = (
self._autoscaling_coordinator.get_allocated_resources.remote(
self._requester_id,
)
)
return self._cached_allocated_resources
def _default_send_resources_request(
bundles: List[ResourceDict],
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Default ``send_resources_request`` implementation for the actor."""
ray.autoscaler.sdk.request_resources(
bundles=bundles, bundle_label_selectors=label_selectors
)
class _AutoscalingCoordinatorActor:
"""An actor to coordinate autoscaling resource requests from different components.
This actor is responsible for:
* Merging received requests and dispatching them to Ray Autoscaler.
* Allocating cluster resources to the requesters.
"""
TICK_INTERVAL_S = 20
def __init__(
self,
get_current_time: Callable[[], float] = time.time,
send_resources_request: Callable[
[List[ResourceDict], Optional[List[Dict[str, str]]]], None
] = _default_send_resources_request,
get_cluster_nodes: Callable[[], List[Dict]] = ray.nodes,
):
self._get_current_time = get_current_time
self._send_resources_request = send_resources_request
self._get_cluster_nodes = get_cluster_nodes
self._ongoing_reqs: Dict[str, OngoingRequest] = {}
# Map from requester id to its subcluster selector.
self._subcluster_selectors: Dict[str, Optional[Dict[str, str]]] = {}
# Node resources bucketed by their ``SUBCLUSTER_LABEL_KEY`` value.
# Nodes without the key fall under ``DEFAULT_SUBCLUSTER``.
self._cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = {}
# Lock for thread-safe access to shared state from the background
self._lock = threading.Lock()
self._update_cluster_node_resources()
# This is an actor, so the following check should always be True.
# It's only needed for unit tests.
if ray.is_initialized():
# Start a thread to perform periodical operations.
def tick_thread_run():
while True:
time.sleep(self.TICK_INTERVAL_S)
self._tick()
self._tick_thread = threading.Thread(target=tick_thread_run, daemon=True)
self._tick_thread.start()
def _tick(self):
"""Used to perform periodical operations, e.g., purge expired requests,
merge and send requests, check cluster resource updates, etc."""
with self._lock:
self._merge_and_send_requests()
self._update_cluster_node_resources()
self._reallocate_resources()
def request_resources(
self,
requester_id: str,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
subcluster_selector: Optional[Dict[str, str]] = None,
) -> None:
logger.debug(
"Received request from %s: %s "
"(label_selectors=%s, subcluster_selector=%s).",
requester_id,
resources,
label_selectors,
subcluster_selector,
)
if label_selectors is None:
label_selectors = [{} for _ in resources]
elif len(label_selectors) != len(resources):
raise ValueError(
f"label_selectors length ({len(label_selectors)}) must match "
f"resources length ({len(resources)})."
)
if subcluster_selector and label_selectors:
req_subcluster = subcluster_selector.get(SUBCLUSTER_LABEL_KEY)
for i, sel in enumerate(label_selectors):
bundle_subcluster = sel.get(SUBCLUSTER_LABEL_KEY)
if (
bundle_subcluster is not None
and bundle_subcluster != req_subcluster
):
raise ValueError(
f"Bundle {i} label_selector targets subcluster "
f"{bundle_subcluster!r}, but requester is registered to "
f"{req_subcluster!r}. Per-bundle cross-subcluster "
f"allocation is not supported."
)
with self._lock:
now = self._get_current_time()
request_updated = False
old_req = self._ongoing_reqs.get(requester_id)
if old_req is not None:
if request_remaining != old_req.request_remaining:
raise ValueError(
"Cannot change request_remaining flag of an ongoing request."
)
if priority.value != old_req.priority:
raise ValueError("Cannot change priority of an ongoing request.")
if (
requester_id in self._subcluster_selectors
and self._subcluster_selectors[requester_id] != subcluster_selector
):
raise ValueError(
"Cannot change subcluster_selector of an ongoing request "
f"from {self._subcluster_selectors[requester_id]!r} to "
f"{subcluster_selector!r}."
)
request_updated = (
resources != old_req.requested_resources
or label_selectors != old_req.requested_label_selectors
)
old_req.requested_resources = resources
old_req.requested_label_selectors = label_selectors
old_req.expiration_time = now + expire_after_s
else:
request_updated = True
self._ongoing_reqs[requester_id] = OngoingRequest(
first_request_time=now,
requested_resources=resources,
requested_label_selectors=label_selectors,
request_remaining=request_remaining,
priority=priority.value,
expiration_time=now + expire_after_s,
allocated_resources=[],
)
# Write subcluster after all validations so a rejected call
# never leaves the registry on a new subcluster.
self._subcluster_selectors[requester_id] = subcluster_selector
if request_updated:
# If the request has updated, immediately send
# a new request and reallocate resources.
self._merge_and_send_requests()
self._reallocate_resources()
def cancel_request(
self,
requester_id: str,
):
logger.debug("Canceling request for %s.", requester_id)
with self._lock:
if requester_id not in self._ongoing_reqs:
return
del self._ongoing_reqs[requester_id]
self._subcluster_selectors.pop(requester_id, None)
self._merge_and_send_requests()
self._reallocate_resources()
def _purge_expired_requests(self):
now = self._get_current_time()
live = {
requester_id: req
for requester_id, req in self._ongoing_reqs.items()
if req.expiration_time > now
}
for expired_id in self._ongoing_reqs.keys() - live.keys():
self._subcluster_selectors.pop(expired_id, None)
self._ongoing_reqs = live
def _merge_and_send_requests(self):
"""Merge requests and send them to Ray Autoscaler.
Each bundle's forwarded selector is the union of its per-bundle
``requested_label_selectors`` entry and the requester's
``subcluster_selector``. The subcluster pin wins on key conflict,
so the autoscaler always sees the correct subcluster regardless
of what the per-bundle selectors contain.
"""
self._purge_expired_requests()
merged_req: List[ResourceDict] = []
merged_selectors: List[Dict[str, str]] = []
for requester_id, req in self._ongoing_reqs.items():
merged_req.extend(req.requested_resources)
subcluster_selector = self._subcluster_selectors.get(requester_id) or {}
for per_bundle in req.requested_label_selectors:
merged_selectors.append({**per_bundle, **subcluster_selector})
if any(merged_selectors):
self._send_resources_request(merged_req, label_selectors=merged_selectors)
else:
self._send_resources_request(merged_req)
def get_allocated_resources(self, requester_id: str) -> List[ResourceDict]:
"""Get the allocated resources for the requester."""
with self._lock:
if requester_id not in self._ongoing_reqs:
return []
return self._ongoing_reqs[requester_id].allocated_resources
def _maybe_subtract_resources(self, res1: ResourceDict, res2: ResourceDict) -> bool:
"""If res2<=res1, subtract res2 from res1 in-place, and return True.
Otherwise return False."""
if any(res1.get(key, 0) < res2[key] for key in res2):
return False
for key in res2:
if key in res1:
res1[key] -= res2[key]
return True
def _update_cluster_node_resources(self) -> bool:
"""Update cluster resources bucketed by subcluster. Return True if changed."""
def _is_node_eligible(node):
# Exclude dead nodes.
if not node["Alive"]:
return False
resources = node["Resources"]
# Exclude the head node if it doesn't have CPUs and GPUs,
# because the object store is not usable.
if HEAD_NODE_RESOURCE_LABEL in resources and (
resources.get("CPU", 0) == 0 and resources.get("GPU", 0) == 0
):
return False
return True
nodes = list(filter(_is_node_eligible, self._get_cluster_nodes()))
nodes = sorted(nodes, key=lambda node: node.get("NodeID", ""))
cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = {}
for node in nodes:
# Safeguard against case where the value of Labels is None.
labels = node.get("Labels") or {}
subcluster = labels.get(SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER)
cluster_node_resources.setdefault(subcluster, []).append(node["Resources"])
if cluster_node_resources == self._cluster_node_resources:
return False
logger.debug("Cluster resources updated: %s.", cluster_node_resources)
self._cluster_node_resources = cluster_node_resources
return True
def _reallocate_resources(self):
"""Reallocate cluster resources.
Each requester's subcluster comes from its ``subcluster_selector``.
A requester without one is eligible only for the ``None`` bucket.
"""
now = self._get_current_time()
cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = copy.deepcopy(
self._cluster_node_resources
)
live_items = [
(req_id, req)
for req_id, req in self._ongoing_reqs.items()
if req.expiration_time >= now
]
live_items.sort(key=lambda item: item[1])
def _subcluster_of(requester_id: str) -> Optional[str]:
selector = self._subcluster_selectors.get(requester_id)
return (selector or {}).get(SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER)
# TODO(hchen): Optimize the following triple loop.
for requester_id, ongoing_req in live_items:
ongoing_req.allocated_resources = []
subcluster = _subcluster_of(requester_id)
for bundle in ongoing_req.requested_resources:
for node_resource in cluster_node_resources.get(subcluster, []):
if self._maybe_subtract_resources(node_resource, bundle):
ongoing_req.allocated_resources.append(bundle)
break
# Allocate remaining resources. Multiple concurrent requesters in
# the same subcluster split that subcluster's leftovers equally.
remaining_items = [
(req_id, req) for req_id, req in live_items if req.request_remaining
]
for subcluster, node_resources in cluster_node_resources.items():
eligible = [
req
for req_id, req in remaining_items
if _subcluster_of(req_id) == subcluster
]
if not eligible:
continue
for node_resource in node_resources:
# Integer division may leave some resources unallocated.
divided = {k: v // len(eligible) for k, v in node_resource.items()}
if not any(v > 0 for v in divided.values()):
continue
for r in eligible:
r.allocated_resources.append(divided)
if logger.isEnabledFor(logging.DEBUG):
msg = "Allocated resources:\n"
for requester_id, ongoing_req in self._ongoing_reqs.items():
allocated_resources_log_str = _format_resources_for_log(
ongoing_req.allocated_resources
)
msg += f"Requester {requester_id}: {allocated_resources_log_str}\n"
logger.debug(msg)
_get_or_create_lock = threading.Lock()
def get_or_create_autoscaling_coordinator():
"""Get or create the AutoscalingCoordinator actor."""
# Create the actor on the local node,
# to reduce network overhead.
scheduling_strategy = NodeAffinitySchedulingStrategy(
ray.get_runtime_context().get_node_id(),
soft=False,
)
actor_cls = ray.remote(num_cpus=0, max_restarts=-1, max_task_retries=-1)(
_AutoscalingCoordinatorActor
).options(
name="AutoscalingCoordinator",
namespace="AutoscalingCoordinator",
get_if_exists=True,
lifetime="detached",
scheduling_strategy=scheduling_strategy,
)
# NOTE: Need the following lock, because Ray Core doesn't allow creating the same
# actor from multiple threads simultaneously.
with _get_or_create_lock:
return actor_cls.remote()
@@ -0,0 +1,428 @@
import logging
import math
import time
from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
import ray
from .base_autoscaling_coordinator import AutoscalingCoordinator, ResourceDict
from .default_autoscaling_coordinator import (
DEFAULT_SUBCLUSTER,
SUBCLUSTER_LABEL_KEY,
DefaultAutoscalingCoordinator,
)
from .resource_utilization_gauge import (
ResourceUtilizationGauge,
RollingLogicalUtilizationGauge,
)
from .util import cap_resource_request_to_limits, is_autoscaling_enabled
from ray._common.utils import env_bool, env_float, env_integer
from ray.data._internal.cluster_autoscaler import ClusterAutoscaler
from ray.data._internal.execution.interfaces.execution_options import ExecutionResources
from ray.data._internal.execution.util import memory_string
from ray.data._internal.util import GiB
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _NodeResourceSpec:
cpu: int
gpu: int
mem: int
def __post_init__(self):
assert isinstance(self.cpu, int)
assert self.cpu >= 0
assert isinstance(self.gpu, int)
assert self.gpu >= 0
assert isinstance(self.mem, int)
assert self.mem >= 0
def __str__(self):
return (
"{"
+ f"CPU: {self.cpu}, GPU: {self.gpu}, memory: {memory_string(self.mem)}"
+ "}"
)
@classmethod
def of(cls, *, cpu=0, gpu=0, mem=0):
cpu = math.floor(cpu)
gpu = math.floor(gpu)
# Round memory to the nearest 0.1 GiB so that nodes of the same type
# with slightly different reported physical memory are grouped together.
mem = int(round(mem / GiB, 1) * GiB) if mem > 0 else 0
return cls(cpu=cpu, gpu=gpu, mem=mem)
@classmethod
def from_bundle(cls, bundle: Dict[str, Any]) -> "_NodeResourceSpec":
return _NodeResourceSpec.of(
cpu=bundle.get("CPU", 0),
gpu=bundle.get("GPU", 0),
mem=bundle.get("memory", 0),
)
def to_bundle(self):
return {"CPU": self.cpu, "GPU": self.gpu, "memory": self.mem}
def _get_node_resource_spec_and_count(
subcluster: Optional[str] = DEFAULT_SUBCLUSTER,
) -> Dict[_NodeResourceSpec, int]:
"""Get the unique node resource specs and their count in the cluster,
scoped to a single subcluster.
The returned specs are the scalable worker shapes used to build scale-up
requests, so the head node is deliberately excluded:
* Head node *instances* are not counted (they can't be used to schedule tasks).
* A node group *config* dedicated to the head node is dropped as well
(``max_count == 1`` and a shape matching the running head node).
Otherwise its shape would be requested as an extra node even though the
head can't be scaled. Groups that can also host workers
(``max_count > 1``) or that have a non-head shape are kept, including
worker types with zero running instances (scale-from-zero).
Quirk: the returned dict also contains a ``node_type: 0`` (ex: `"m5.xlarge": 0`) entry for every
node type registered in ``cluster_config.node_group_configs`` that
isn't included in this subcluster. ``get_cluster_config()``
reports node types but not labels, so the only way to know a
shape's subcluster is to inspect live nodes. Harmless: for example,
if m5.xlarge nodes only exist in the training subcluster, the validation
dataset will emit pending-bundle scale-up demand for foo nodes
stamped with the validation label, which the autoscaler can never
satisfy.
TODO: get labels from cluster config so the catalog can be filtered.
Args:
subcluster: The value at ``SUBCLUSTER_LABEL_KEY`` to match against.
The default ``DEFAULT_SUBCLUSTER`` (None) selects nodes with no
subcluster label.
Returns:
A mapping from each scalable worker node shape to its current count of
running instances (0 for shapes discovered only from the cluster
config).
"""
nodes_resource_spec_count = defaultdict(int)
# Split nodes into the head node and worker nodes. There is exactly one head
# node, and it can't be scaled up, so it's excluded from the counts and used
# below to identify a node group dedicated to the head. Worker nodes are
# further scoped to the requester's subcluster, so foreign subclusters'
# shapes and counts don't leak into this requester's active / pending
# bundles. Head detection is intentionally not subcluster-scoped: the head
# node group is global.
head_node_spec = None
worker_node_resources = []
for node in ray.nodes():
if not node["Alive"]:
continue
if "node:__internal_head__" in node["Resources"]:
head_node_spec = _NodeResourceSpec.from_bundle(node["Resources"])
continue
if (node.get("Labels") or {}).get(
SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER
) == subcluster:
worker_node_resources.append(node["Resources"])
cluster_config = ray._private.state.state.get_cluster_config()
if cluster_config and cluster_config.node_group_configs:
for node_group_config in cluster_config.node_group_configs:
if not node_group_config.resources or node_group_config.max_count == 0:
continue
node_resource_spec = _NodeResourceSpec.from_bundle(
node_group_config.resources
)
# Skip a node group dedicated to the head node, since it can't be scaled up and thus shouldn't be counted towards the current cluster capacity or used as a template for scaling up.
if (
node_group_config.max_count == 1
and node_resource_spec == head_node_spec
):
continue
nodes_resource_spec_count[node_resource_spec] = 0
for r in worker_node_resources:
node_resource_spec = _NodeResourceSpec.from_bundle(r)
nodes_resource_spec_count[node_resource_spec] += 1
return nodes_resource_spec_count
class DefaultClusterAutoscalerV2(ClusterAutoscaler):
"""Ray Data's second cluster autoscaler implementation.
It works in the following way:
* Check the average cluster utilization (CPU and memory)
in a time window (by default 10s). If the utilization is above a threshold (by
default 0.75), send a request to Ray's autoscaler to scale up the cluster.
* Unlike previous implementation, each resource bundle in the request is a node
resource spec, rather than an `incremental_resource_usage()`. This allows us
to directly scale up nodes.
* Cluster scaling down isn't handled here. It depends on the idle node
termination.
"""
# Default cluster utilization threshold to trigger scaling up.
DEFAULT_CLUSTER_SCALING_UP_UTIL_THRESHOLD: float = env_float(
"RAY_DATA_CLUSTER_SCALING_UP_UTIL_THRESHOLD",
0.75,
)
# Default time window in seconds to calculate the average of cluster utilization.
DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S: int = env_integer(
"RAY_DATA_CLUSTER_UTIL_AVG_WINDOW_S",
10,
)
# Default number of nodes to add per node type.
DEFAULT_CLUSTER_SCALING_UP_DELTA: int = env_integer(
"RAY_DATA_CLUSTER_SCALING_UP_DELTA",
1,
)
# Min number of seconds between two autoscaling requests.
MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS: int = env_integer(
"RAY_DATA_MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS",
10,
)
# The time in seconds after which an autoscaling request will expire.
AUTOSCALING_REQUEST_EXPIRE_TIME_S: int = env_integer(
"RAY_DATA_AUTOSCALING_REQUEST_EXPIRE_TIME_S",
180,
)
# When utilization drops below the scale-up threshold, keep renewing the last
# explicit request for a short time before releasing it.
DEFAULT_LOW_UTIL_REQUEST_RELEASE_DELAY_S: float = env_float(
"RAY_DATA_LOW_UTIL_REQUEST_RELEASE_DELAY_S",
180,
)
# Whether to disable INFO-level logs.
RAY_DATA_DISABLE_AUTOSCALER_LOGGING = env_bool(
"RAY_DATA_DISABLE_AUTOSCALER_LOGGING", False
)
def __init__(
self,
resource_manager: "ResourceManager",
execution_id: str,
resource_limits: ExecutionResources = ExecutionResources.inf(),
resource_utilization_calculator: Optional[ResourceUtilizationGauge] = None,
cluster_scaling_up_util_threshold: float = DEFAULT_CLUSTER_SCALING_UP_UTIL_THRESHOLD, # noqa: E501
cluster_scaling_up_delta: float = DEFAULT_CLUSTER_SCALING_UP_DELTA,
cluster_util_avg_window_s: float = DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S,
min_gap_between_autoscaling_requests_s: float = MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS, # noqa: E501
low_util_request_release_delay_s: float = DEFAULT_LOW_UTIL_REQUEST_RELEASE_DELAY_S, # noqa: E501
autoscaling_coordinator: Optional[AutoscalingCoordinator] = None,
get_node_counts: Optional[Callable[[], Dict[_NodeResourceSpec, int]]] = None,
get_time: Callable[[], float] = time.time,
label_selector: Optional[Dict[str, str]] = None,
):
assert cluster_scaling_up_delta > 0
assert cluster_util_avg_window_s > 0
assert min_gap_between_autoscaling_requests_s >= 0
assert low_util_request_release_delay_s >= 0
if resource_utilization_calculator is None:
resource_utilization_calculator = RollingLogicalUtilizationGauge(
resource_manager,
cluster_util_avg_window_s=cluster_util_avg_window_s,
execution_id=execution_id,
)
self._resource_limits = resource_limits
self._label_selector = label_selector or {}
self._resource_utilization_calculator = resource_utilization_calculator
# Threshold of cluster utilization to trigger scaling up.
self._cluster_scaling_up_util_threshold = cluster_scaling_up_util_threshold
self._cluster_scaling_up_delta = int(math.ceil(cluster_scaling_up_delta))
self._min_gap_between_autoscaling_requests_s = (
min_gap_between_autoscaling_requests_s
)
self._low_util_request_release_delay_s = low_util_request_release_delay_s
# Last time when a request was sent to Ray's autoscaler.
self._last_request_time = 0
# Track the last non-empty explicit request so low-utilization heartbeats
# can keep it alive briefly without turning allocated remaining-share
# resources into explicit autoscaler demand.
self._last_non_empty_resource_request: List[ResourceDict] = []
self._last_non_empty_request_time: Optional[float] = None
# Unique identifier for the cluster autoscaler as a requester for
# the autoscaling coordinator.
self._requester_id = f"data-{execution_id}"
if autoscaling_coordinator is None:
autoscaling_coordinator = DefaultAutoscalingCoordinator(
requester_id=self._requester_id,
subcluster_selector=label_selector,
)
self._autoscaling_coordinator = autoscaling_coordinator
if get_node_counts is None:
# Scope node-shape/count discovery to this requester's subcluster
# so try_trigger_scaling doesn't pull node shapes / counts from
# other subclusters into ``active_bundles`` / ``pending_bundles``.
subcluster = self._label_selector.get(
SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER
)
get_node_counts = lambda: _get_node_resource_spec_and_count( # noqa: E731
subcluster=subcluster
)
self._get_node_counts = get_node_counts
self._get_time = get_time
self._autoscaling_enabled = is_autoscaling_enabled()
# Register with the coordinator immediately so the actor knows about this
# requester before the first ``get_allocated_resources call``. The cached value
# returned by ``get_allocated_resources`` (and thus ``get_total_resources``) will
# be empty until the actor responds with the first allocation (cold-start).
self._send_resource_request([])
def try_trigger_scaling(self):
# Note, should call this method before checking `_last_request_time`,
# in order to update the average cluster utilization.
self._resource_utilization_calculator.observe()
# Limit the frequency of autoscaling requests.
now = self._get_time()
if now - self._last_request_time < self._min_gap_between_autoscaling_requests_s:
return
util = self._resource_utilization_calculator.get()
if (
util.cpu < self._cluster_scaling_up_util_threshold
and util.gpu < self._cluster_scaling_up_util_threshold
and util.memory < self._cluster_scaling_up_util_threshold
and util.object_store_memory < self._cluster_scaling_up_util_threshold
):
logger.debug(
"Cluster utilization is below threshold: "
f"CPU={util.cpu:.2f}, GPU={util.gpu:.2f}, memory={util.memory:.2f}, "
f"object_store_memory={util.object_store_memory:.2f}."
)
self._send_resource_request(None)
return
# We separate active bundles (existing nodes) from pending bundles (scale-up delta)
# to ensure existing nodes' resources are never crowded out by scale-up requests.
# TODO(hchen): We scale up all nodes by the same delta for now.
# We may want to distinguish different node types based on their individual
# utilization.
active_bundles = []
pending_bundles = []
node_resource_spec_count = self._get_node_counts()
for node_resource_spec, count in node_resource_spec_count.items():
bundle = node_resource_spec.to_bundle()
# Bundles for existing nodes -> active (must include)
active_bundles.extend([bundle] * count)
# Bundles for scale-up delta -> pending (best-effort)
pending_bundles.extend([bundle] * self._cluster_scaling_up_delta)
# Cap the resource request to respect user-configured limits.
# Active bundles (existing nodes) are always included; pending bundles
# (scale-up requests) are best-effort.
resource_request = cap_resource_request_to_limits(
active_bundles, pending_bundles, self._resource_limits
)
if resource_request != active_bundles:
self._log_resource_request(util, active_bundles, resource_request)
self._send_resource_request(resource_request)
def _log_resource_request(
self,
current_utilization: ExecutionResources,
active_bundles: List[Dict[str, float]],
resource_request: List[Dict[str, float]],
) -> None:
message = (
"The utilization of one or more logical resource is higher than the "
f"specified threshold of {self._cluster_scaling_up_util_threshold:.0%}: "
f"CPU={current_utilization.cpu:.0%}, GPU={current_utilization.gpu:.0%}, "
f"memory={current_utilization.memory:.0%}, "
f"object_store_memory={current_utilization.object_store_memory:.0%}. "
f"Requesting {self._cluster_scaling_up_delta} node(s) of each shape:"
)
current_node_counts = Counter(
[_NodeResourceSpec.from_bundle(bundle) for bundle in active_bundles]
)
requested_node_counts = Counter(
[_NodeResourceSpec.from_bundle(bundle) for bundle in resource_request]
)
for node_spec, requested_count in requested_node_counts.items():
current_count = current_node_counts.get(node_spec, 0)
message += f" [{node_spec}: {current_count} -> {requested_count}]"
if self.RAY_DATA_DISABLE_AUTOSCALER_LOGGING or not self._autoscaling_enabled:
level = logging.DEBUG
else:
level = logging.INFO
logger.log(level, message)
def _should_keep_non_empty_request(self, now: float) -> bool:
return (
self._last_non_empty_request_time is not None
and now - self._last_non_empty_request_time
< self._low_util_request_release_delay_s
)
def _send_resource_request(
self,
resource_request: Optional[List[ResourceDict]],
):
now = self._get_time()
update_non_empty_request_state = True
if resource_request is None:
if self._should_keep_non_empty_request(now):
resource_request = self._last_non_empty_resource_request
update_non_empty_request_state = False
else:
# Renew our registration on AutoscalingCoordinator without
# keeping explicit autoscaler demand alive.
resource_request = []
# Make autoscaler resource request.
self._autoscaling_coordinator.request_resources(
resources=resource_request,
expire_after_s=self.AUTOSCALING_REQUEST_EXPIRE_TIME_S,
request_remaining=True,
)
if resource_request and update_non_empty_request_state:
self._last_non_empty_resource_request = [
bundle.copy() for bundle in resource_request
]
self._last_non_empty_request_time = now
elif not resource_request:
self._last_non_empty_resource_request = []
self._last_non_empty_request_time = None
self._last_request_time = now
def on_executor_shutdown(self):
# Cancel the resource request when the executor is shutting down.
try:
self._autoscaling_coordinator.cancel_request()
except Exception:
# cancel_request is fire-and-forget and shouldn't raise, but guard
# against unexpected Ray Core errors at submit time. At shutdown
# there's nothing useful to do except log and let the request expire.
msg = (
f"Failed to cancel resource request for {self._requester_id}."
" The request will still expire after the timeout of"
f" {self._min_gap_between_autoscaling_requests_s} seconds."
)
logger.warning(msg, exc_info=True)
def get_total_resources(self) -> ExecutionResources:
"""Get total resources available from the autoscaling coordinator."""
resources = self._autoscaling_coordinator.get_allocated_resources()
total = ExecutionResources.zero()
for res in resources:
total = total.add(ExecutionResources.from_resource_dict(res))
return total
@@ -0,0 +1,83 @@
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
class FakeAutoscalingCoordinator(AutoscalingCoordinator):
"""A lightweight implementation for testing.
This implementation always allocates the requested resources to the
requester. It doesn't support the `priority` parameter.
"""
@dataclass
class Allocation:
resources: List[ResourceDict]
expiration_time_s: float
request_remaining: bool
def __init__(
self,
get_time: Callable[[], float] = time.time,
initial_cluster_resources: Optional[List[ResourceDict]] = None,
):
"""Initialize the coordinator.
Args:
get_time: A function that returns the current time in seconds. This is a
seam for testing.
initial_cluster_resources: If the requester sends an empty request and
``request_remaining`` is True, the coordinator allocates these resources
to the requester. Otherwise, the coordinator allocates the requested
resources.
"""
if initial_cluster_resources is None:
initial_cluster_resources = []
self._get_time = get_time
self._initial_cluster_resources = initial_cluster_resources
self._allocation: Optional[FakeAutoscalingCoordinator.Allocation] = None
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
subcluster_selector: Optional[Dict[str, str]] = None,
) -> None:
if priority != ResourceRequestPriority.MEDIUM:
raise NotImplementedError(
"This fake implementation doesn't support the `priority` parameter."
)
if not resources and request_remaining:
resources = [r.copy() for r in self._initial_cluster_resources]
# Always accept the request and record it.
self._allocation = self.Allocation(
resources=resources,
expiration_time_s=self._get_time() + expire_after_s,
request_remaining=request_remaining,
)
def cancel_request(self) -> None:
self._allocation = None
def get_allocated_resources(self) -> List[ResourceDict]:
"""Return the allocated resources if they haven't expired."""
if self._allocation is None:
return []
if self._allocation.expiration_time_s < self._get_time():
self._allocation = None
return []
return [r.copy() for r in self._allocation.resources]
@@ -0,0 +1,141 @@
import abc
import math
from dataclasses import dataclass
from typing import Optional
from ray.data._internal.average_calculator import TimeWindowAverageCalculator
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.util.metrics import Gauge
@dataclass(frozen=True)
class ClusterUtil:
cpu: float = 0.0
gpu: float = 0.0
memory: float = 0.0
object_store_memory: float = 0.0
def __post_init__(self):
# If we overcommit tasks, the logical utilization can exceed 1.0.
assert math.isfinite(self.cpu) and 0 <= self.cpu, self.cpu
assert math.isfinite(self.gpu) and 0 <= self.gpu, self.gpu
assert math.isfinite(self.memory) and 0 <= self.memory, self.memory
assert (
math.isfinite(self.object_store_memory) and 0 <= self.object_store_memory
), self.object_store_memory
class ResourceUtilizationGauge(abc.ABC):
@abc.abstractmethod
def observe(self):
"""Observe the cluster utilization."""
...
@abc.abstractmethod
def get(self) -> ClusterUtil:
"""Get the resource cluster utilization."""
...
class RollingLogicalUtilizationGauge(ResourceUtilizationGauge):
# Default time window in seconds to calculate the average of cluster utilization.
DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S: int = 10
def __init__(
self,
resource_manager: ResourceManager,
*,
cluster_util_avg_window_s: float = DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S,
execution_id: Optional[str] = None,
):
self._resource_manager = resource_manager
self._execution_id = execution_id
self._cluster_cpu_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_gpu_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_mem_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_obj_mem_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_cpu_utilization_gauge = None
self._cluster_gpu_utilization_gauge = None
self._cluster_mem_utilization_gauge = None
self._cluster_object_store_memory_utilization_gauge = None
if self._execution_id is not None:
self._cluster_cpu_utilization_gauge = Gauge(
"data_cluster_cpu_utilization",
description="Cluster utilization % (CPU)",
tag_keys=("dataset",),
)
self._cluster_gpu_utilization_gauge = Gauge(
"data_cluster_gpu_utilization",
description="Cluster utilization % (GPU)",
tag_keys=("dataset",),
)
self._cluster_mem_utilization_gauge = Gauge(
"data_cluster_mem_utilization",
description="Cluster utilization % (Memory)",
tag_keys=("dataset",),
)
self._cluster_object_store_memory_utilization_gauge = Gauge(
"data_cluster_object_store_memory_utilization",
description="Cluster utilization % (Object Store Memory)",
tag_keys=("dataset",),
)
def observe(self):
"""Report the cluster utilization based on global usage / global limits."""
def save_div(numerator, denominator):
if not denominator:
return 0
else:
return numerator / denominator
global_usage = self._resource_manager.get_global_usage()
global_limits = self._resource_manager.get_global_limits()
cpu_util = save_div(global_usage.cpu, global_limits.cpu)
gpu_util = save_div(global_usage.gpu, global_limits.gpu)
mem_util = save_div(global_usage.memory, global_limits.memory)
obj_store_mem_util = save_div(
global_usage.object_store_memory, global_limits.object_store_memory
)
self._cluster_cpu_util_calculator.report(cpu_util)
self._cluster_gpu_util_calculator.report(gpu_util)
self._cluster_mem_util_calculator.report(mem_util)
self._cluster_obj_mem_util_calculator.report(obj_store_mem_util)
if self._execution_id is not None:
tags = {"dataset": self._execution_id}
if self._cluster_cpu_utilization_gauge is not None:
self._cluster_cpu_utilization_gauge.set(cpu_util * 100, tags=tags)
if self._cluster_gpu_utilization_gauge is not None:
self._cluster_gpu_utilization_gauge.set(gpu_util * 100, tags=tags)
if self._cluster_mem_utilization_gauge is not None:
self._cluster_mem_utilization_gauge.set(mem_util * 100, tags=tags)
if self._cluster_object_store_memory_utilization_gauge is not None:
self._cluster_object_store_memory_utilization_gauge.set(
obj_store_mem_util * 100, tags=tags
)
def get(self) -> ClusterUtil:
"""Get the average cluster utilization based on global usage / global limits."""
# Clamp to 0 to handle floating-point drift in the rolling average.
return ClusterUtil(
cpu=max(0, self._cluster_cpu_util_calculator.get_average() or 0),
gpu=max(0, self._cluster_gpu_util_calculator.get_average() or 0),
memory=max(0, self._cluster_mem_util_calculator.get_average() or 0),
object_store_memory=max(
0, self._cluster_obj_mem_util_calculator.get_average() or 0
),
)
@@ -0,0 +1,127 @@
from typing import Dict, TypeVar
from ray.data._internal.execution.interfaces import ExecutionResources
# The math functions defined in this module use a generic type rather than
# `PhysicalOperator` so it's easier to test. We already pass in all of the necessary
# inputs, so the actual type doesn't matter.
T = TypeVar("T")
_SCHEDULABLE_RESOURCE_NAMES = ("cpu", "gpu", "memory")
def allocate_resources(
throughput: float,
*,
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
) -> Dict[T, ExecutionResources]:
"""Allocate resources for a pipeline to sustain the given throughput.
Key insight: in a pipeline, all operators must sustain the same throughput T.
Operator i with per-task rate r_i needs T/r_i tasks to sustain T. So maximizing
throughput is equivalent to finding the largest feasible T, then deriving task
counts from it.
Args:
throughput: The throughput for the pipeline in the same units as the rates.
rates: The rate at which a task or actor produces outputs for each operator.
resource_requirements: The logical resources required to schedule a task or
actor for each operator.
Returns:
A dictionary mapping operators to the allocated resources.
"""
assert throughput >= 0, "Throughput must be non-negative"
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
if not rates:
return {}
if throughput == 0:
return {op: ExecutionResources.zero() for op in rates}
# NOTE: This implementation computes fractional task counts. In practice, you
# can't schedule a fractional task or actor, so the allocations might be infeasible.
task_counts = {op: throughput / rate for op, rate in rates.items()}
return {op: resource_requirements[op].scale(task_counts[op]) for op in rates}
def compute_optimal_throughput(
*,
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
resource_limits: ExecutionResources,
concurrency_limits: Dict[T, int | None],
) -> float:
"""Compute the optimal throughput for a pipeline.
The optimal throughput is bounded by two constraints (we take the tightest):
1. Resource limits — total resource usage across all operators must fit the
budget.
2. Concurrency limits — each operator's task count cannot exceed its limit.
Args:
rates: The rate at which a task or actor produces outputs for each operator.
resource_requirements: The logical resources required to schedule a task or
actor for each operator.
resource_limits: The resource limits for the cluster.
concurrency_limits: The maximum number of tasks or actors that can be scheduled
concurrently for each operator.
Returns:
The optimal throughput for the pipeline in the same units as the rates.
"""
assert rates, "Rates must be non-empty"
return min(
_max_throughput_from_resources(rates, resource_requirements, resource_limits),
_max_throughput_from_concurrency(rates, concurrency_limits),
)
def _max_throughput_from_resources(
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
resource_limits: ExecutionResources,
) -> float:
"""For each resource type, compute the max throughput the resource budget allows."""
assert rates, "Rates must be non-empty"
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
assert (
rates.keys() <= resource_requirements.keys()
), "You must provide a resource requirement for each operator with a rate."
max_throughput = float("inf")
for resource_name in _SCHEDULABLE_RESOURCE_NAMES:
resource_limit = getattr(resource_limits, resource_name)
resource_cost_per_unit_throughput = sum(
getattr(resource_requirements[op], resource_name) / rates[op]
for op in rates
)
if resource_cost_per_unit_throughput > 0:
max_throughput = min(
max_throughput, resource_limit / resource_cost_per_unit_throughput
)
assert max_throughput >= 0, "Max throughput must be non-negative"
return max_throughput
def _max_throughput_from_concurrency(
rates: Dict[T, float],
concurrency_limits: Dict[T, int | None],
) -> float:
"""Each operator's throughput is capped at rate * concurrency_limit."""
assert rates, "Rates must be non-empty"
assert (
rates.keys() <= concurrency_limits.keys()
), "You must provide a concurrency limit for each operator with a rate."
# Convert `None` to float("inf") for operators with no concurrency limit
normalized_concurrency_limits: Dict[T, float] = {
op: limit if limit is not None else float("inf")
for op, limit in concurrency_limits.items()
}
return min(rates[op] * normalized_concurrency_limits[op] for op in rates)
@@ -0,0 +1,97 @@
import logging
from typing import Dict, List
from ray.data._internal.execution.interfaces import ExecutionResources
logger = logging.getLogger(__name__)
def is_autoscaling_enabled() -> bool:
"""Check if any node type has autoscaling enabled (can scale up).
A node type is autoscalable if max_count == -1 (unlimited) or
max_count > min_count. If no cluster config is available or no node type
is autoscalable, returns False.
"""
import ray._private.state
cluster_config = ray._private.state.state.get_cluster_config()
if not cluster_config or not cluster_config.node_group_configs:
return False
return any(
ngc.max_count == -1 or ngc.max_count > ngc.min_count
for ngc in cluster_config.node_group_configs
if ngc.resources and ngc.max_count != 0
)
def cap_resource_request_to_limits(
active_bundles: List[Dict],
pending_bundles: List[Dict],
resource_limits: ExecutionResources,
) -> List[Dict]:
"""Cap the resource request to not exceed user-configured resource limits.
Active bundles (for running tasks or existing nodes) are always included first
since they represent resources already in use. Pending bundles (for future work
or scale-up requests) are then added best-effort, sorted smallest-first to
maximize packing within limits.
This ensures that resources for already-running tasks are never crowded out
by pending work from smaller operators.
Args:
active_bundles: Bundles for already-running tasks or existing nodes
(must include - these represent current resource usage).
pending_bundles: Bundles for pending work or scale-up requests
(best-effort - only added if within limits).
resource_limits: The user-configured resource limits.
Returns:
A list of resource bundles that respects user limits, with active bundles
always included first.
"""
# If no explicit limits are set (all infinite), return everything
if resource_limits == ExecutionResources.inf():
return active_bundles + pending_bundles
# Always include active bundles first - they're already running/allocated
capped_request = list(active_bundles)
total = ExecutionResources.zero()
for bundle in active_bundles:
total = total.add(ExecutionResources.from_resource_dict(bundle))
# Sort pending bundles by size (smallest first) to maximize packing.
# This ensures smaller bundles aren't excluded due to larger bundles
# appearing earlier in arbitrary iteration order.
def bundle_sort_key(bundle: Dict) -> tuple:
return (
bundle.get("CPU", 0),
bundle.get("GPU", 0),
bundle.get("memory", 0),
)
sorted_pending = sorted(pending_bundles, key=bundle_sort_key)
for bundle in sorted_pending:
new_total = total.add(ExecutionResources.from_resource_dict(bundle))
# Skip bundles that don't fit, continue checking smaller ones
if not new_total.satisfies_limit(resource_limits):
continue
capped_request.append(bundle)
total = new_total
total_input = len(active_bundles) + len(pending_bundles)
if len(capped_request) < total_input:
logger.debug(
f"Capped autoscaling resource request from {total_input} "
f"bundles to {len(capped_request)} bundles to respect "
f"user-configured resource limits: {resource_limits}. "
f"({len(active_bundles)} active bundles kept, "
f"{len(capped_request) - len(active_bundles)}/{len(pending_bundles)} "
f"pending bundles included)."
)
return capped_request