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

256 lines
10 KiB
Python

import logging
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set
from ray.autoscaler._private.constants import (
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
DISABLE_NODE_UPDATERS_KEY,
FOREGROUND_NODE_LAUNCH_KEY,
)
from ray.autoscaler._private.util import NodeID, NodeIP, NodeKind, NodeStatus, NodeType
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_STATUS,
TAG_RAY_REPLICA_INDEX,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
@dataclass
class ScaleRequest:
"""Stores desired scale computed by the autoscaler.
Attributes:
desired_num_workers: Map of worker NodeType to desired number of workers of
that type.
workers_to_delete: List of ids of nodes that should be removed.
"""
desired_num_workers: Dict[NodeType, int] = field(default_factory=dict)
workers_to_delete: Set[NodeID] = field(default_factory=set)
@dataclass
class NodeData:
"""Stores all data about a Ray node needed by the autoscaler.
Attributes:
kind: Whether the node is the head or a worker.
type: The user-defined type of the node.
replica_index: An identifier for nodes in a replica of a TPU worker group.
This value is set as a Pod label by a GKE webhook when TPUs are requested
ip: Cluster-internal ip of the node. ip can be None if the ip
has not yet been assigned.
status: The status of the node. You must adhere to the following semantics
for status:
* The status must be "up-to-date" if and only if the node is running.
* The status must be "update-failed" if and only if the node is in an
unknown or failed state.
* If the node is in a pending (starting-up) state, the status should be
a brief user-facing description of why the node is pending.
"""
kind: NodeKind
type: NodeType
ip: Optional[NodeIP]
status: NodeStatus
replica_index: Optional[str] = None
class BatchingNodeProvider(NodeProvider):
"""Abstract subclass of NodeProvider meant for use with external cluster managers.
Batches reads of cluster state into a single method, get_node_data, called at the
start of an autoscaling update.
Batches modifications to cluster state into a single method, submit_scale_request,
called at the end of an autoscaling update.
Implementing a concrete subclass of BatchingNodeProvider only requires overriding
get_node_data() and submit_scale_request().
See the method docstrings for more information.
Note that an autoscaling update may be conditionally
cancelled using the optional method safe_to_scale()
of the root NodeProvider.
"""
def __init__(
self,
provider_config: Dict[str, Any],
cluster_name: str,
) -> None:
NodeProvider.__init__(self, provider_config, cluster_name)
self.node_data_dict: Dict[NodeID, NodeData] = {}
# These flags enforce correct behavior for single-threaded node providers
# which interact with external cluster managers:
assert (
provider_config.get(DISABLE_NODE_UPDATERS_KEY, False) is True
), f"To use BatchingNodeProvider, must set `{DISABLE_NODE_UPDATERS_KEY}:True`."
assert provider_config.get(DISABLE_LAUNCH_CONFIG_CHECK_KEY, False) is True, (
"To use BatchingNodeProvider, must set "
f"`{DISABLE_LAUNCH_CONFIG_CHECK_KEY}:True`."
)
assert (
provider_config.get(FOREGROUND_NODE_LAUNCH_KEY, False) is True
), f"To use BatchingNodeProvider, must set `{FOREGROUND_NODE_LAUNCH_KEY}:True`."
# self.scale_change_needed tracks whether we need to update scale.
# set to True in create_node and terminate_nodes calls
# reset to False in non_terminated_nodes, which occurs at the start of the
# autoscaling update. For good measure, also set to false in post_process.
self.scale_change_needed = False
self.scale_request = ScaleRequest()
# Initialize map of replica indices to nodes in that replica
self.replica_index_to_nodes = defaultdict(list[str])
def get_node_data(self) -> Dict[NodeID, NodeData]:
"""Queries cluster manager for node info. Returns a mapping from node id to
NodeData.
Each NodeData value must adhere to the semantics of the NodeData docstring.
(Note in particular the requirements for NodeData.status.)
Consistency requirement:
If a node id was present in ScaleRequest.workers_to_delete of a previously
submitted scale request, it should no longer be present as a key in
get_node_data.
(Node termination must be registered immediately when submit_scale_request
returns.)
"""
raise NotImplementedError
def submit_scale_request(self, scale_request: ScaleRequest) -> None:
"""Tells the cluster manager which nodes to delete and how many nodes of
each node type to maintain.
Consistency requirement:
If a node id was present in ScaleRequest.workers_to_delete of a previously
submitted scale request, it should no longer be present as key in get_node_data.
(Node termination must be registered immediately when submit_scale_request
returns.)
"""
raise NotImplementedError
def post_process(self) -> None:
"""Submit a scale request if it is necessary to do so."""
if self.scale_change_needed:
self.submit_scale_request(self.scale_request)
self.scale_change_needed = False
def non_terminated_nodes(self, tag_filters: Dict[str, str]) -> List[str]:
self.scale_change_needed = False
self.node_data_dict = self.get_node_data()
# Initialize ScaleRequest
self.scale_request = ScaleRequest(
desired_num_workers=self.cur_num_workers(), # Current scale
workers_to_delete=set(), # No workers to delete yet
)
all_nodes = list(self.node_data_dict.keys())
self.replica_index_to_nodes.clear()
for node_id in all_nodes:
replica_index = self.node_data_dict[node_id].replica_index
# Only add node to map if it belongs to a multi-host podslice
if replica_index is not None:
self.replica_index_to_nodes[replica_index].append(node_id)
# Support filtering by TAG_RAY_NODE_KIND, TAG_RAY_NODE_STATUS, and
# TAG_RAY_USER_NODE_TYPE.
# The autoscaler only uses tag_filters={},
# but filtering by the these keys is useful for testing.
filtered_nodes = [
node
for node in all_nodes
if tag_filters.items() <= self.node_tags(node).items()
]
return filtered_nodes
def cur_num_workers(self):
"""Returns dict mapping node type to the number of nodes of that type."""
# Factor like this for convenient re-use.
return self._cur_num_workers(self.node_data_dict)
def _cur_num_workers(self, node_data_dict: Dict[str, Any]):
num_workers_dict = defaultdict(int)
for node_data in node_data_dict.values():
if node_data.kind == NODE_KIND_HEAD:
# Only track workers.
continue
num_workers_dict[node_data.type] += 1
return num_workers_dict
def node_tags(self, node_id: str) -> Dict[str, str]:
node_data = self.node_data_dict[node_id]
tags = {
TAG_RAY_NODE_KIND: node_data.kind,
TAG_RAY_NODE_STATUS: node_data.status,
TAG_RAY_USER_NODE_TYPE: node_data.type,
}
if node_data.replica_index is not None:
tags[TAG_RAY_REPLICA_INDEX] = node_data.replica_index
return tags
def internal_ip(self, node_id: str) -> str:
return self.node_data_dict[node_id].ip
def create_node(
self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
) -> Optional[Dict[str, Any]]:
node_type = tags[TAG_RAY_USER_NODE_TYPE]
self.scale_request.desired_num_workers[node_type] += count
self.scale_change_needed = True
def terminate_node(self, node_id: str) -> Optional[Dict[str, Any]]:
# Sanity check: We should never try to delete the same node twice.
if node_id in self.scale_request.workers_to_delete:
logger.warning(
f"Autoscaler tried to terminate node {node_id} twice in the same update"
". Skipping termination request."
)
return
# Sanity check: We should never try to delete a node we haven't seen.
if node_id not in self.node_data_dict:
logger.warning(
f"Autoscaler tried to terminate unkown node {node_id}"
". Skipping termination request."
)
return
node_type = self.node_data_dict[node_id].type
# Sanity check: Don't request less than 0 nodes.
if self.scale_request.desired_num_workers[node_type] <= 0:
# This is logically impossible.
raise AssertionError(
"NodeProvider attempted to request less than 0 workers of type "
f"{node_type}. Skipping termination request."
)
# Terminate node
self.scale_request.desired_num_workers[node_type] -= 1
self.scale_request.workers_to_delete.add(node_id)
# Scale down all nodes in replica if node_id is part of a multi-host podslice
tags = self.node_tags(node_id)
if TAG_RAY_REPLICA_INDEX in tags:
node_replica_index = tags[TAG_RAY_REPLICA_INDEX]
for worker_id in self.replica_index_to_nodes[node_replica_index]:
# Check if worker has already been scheduled to delete
if worker_id not in self.scale_request.workers_to_delete:
self.scale_request.workers_to_delete.add(worker_id)
logger.info(
f"Autoscaler terminating node {worker_id} "
f"in multi-host replica {node_replica_index}."
)
self.scale_change_needed = True