1096 lines
38 KiB
Python
1096 lines
38 KiB
Python
from collections import Counter, defaultdict
|
|
from copy import deepcopy
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from itertools import chain
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import ray
|
|
from ray._common.utils import binary_to_hex
|
|
from ray._raylet import GcsClient
|
|
from ray.autoscaler._private import constants
|
|
from ray.autoscaler._private.util import (
|
|
format_pg,
|
|
format_resource_demand_summary,
|
|
parse_usage,
|
|
)
|
|
from ray.autoscaler.v2.schema import (
|
|
NODE_DEATH_CAUSE_RAYLET_DIED,
|
|
ClusterConstraintDemand,
|
|
ClusterStatus,
|
|
LaunchRequest,
|
|
NodeInfo,
|
|
NodeUsage,
|
|
PlacementGroupResourceDemand,
|
|
RayTaskActorDemand,
|
|
ResourceDemand,
|
|
ResourceDemandSummary,
|
|
ResourceRequestByCount,
|
|
ResourceUsage,
|
|
Stats,
|
|
)
|
|
from ray.core.generated.autoscaler_pb2 import (
|
|
AffinityConstraint,
|
|
AntiAffinityConstraint,
|
|
AutoscalingState,
|
|
ClusterResourceState,
|
|
GetClusterStatusReply,
|
|
NodeState,
|
|
NodeStatus,
|
|
PlacementConstraint,
|
|
ResourceRequest,
|
|
ResourceRequestByCount as ResourceRequestByCountProto,
|
|
)
|
|
from ray.core.generated.common_pb2 import (
|
|
LabelSelector,
|
|
LabelSelectorConstraint,
|
|
)
|
|
from ray.experimental.internal_kv import internal_kv_get_gcs_client
|
|
|
|
|
|
def _count_by(data: Any, key: str) -> Dict[str, int]:
|
|
"""
|
|
Count the number of items by the given key.
|
|
|
|
Args:
|
|
data: the data to be counted
|
|
key: the key to count by
|
|
|
|
Returns:
|
|
counts: the counts
|
|
"""
|
|
counts = defaultdict(int)
|
|
for item in data:
|
|
key_name = getattr(item, key)
|
|
counts[key_name] += 1
|
|
return counts
|
|
|
|
|
|
class ProtobufUtil:
|
|
"""
|
|
A utility class for protobuf objects.
|
|
"""
|
|
|
|
@staticmethod
|
|
def to_dict(proto: Any) -> Dict[str, Any]:
|
|
"""
|
|
Convert a protobuf object to a dict.
|
|
|
|
This is a slow conversion, and should only be used for debugging or
|
|
latency insensitve code.
|
|
|
|
Args:
|
|
proto: the protobuf object
|
|
|
|
Returns:
|
|
dict: the dict
|
|
"""
|
|
from ray._private.protobuf_compat import message_to_dict
|
|
|
|
return message_to_dict(
|
|
proto,
|
|
preserving_proto_field_name=True,
|
|
always_print_fields_with_no_presence=True,
|
|
)
|
|
|
|
@staticmethod
|
|
def to_dict_list(protos: List[Any]) -> List[Dict[str, Any]]:
|
|
"""
|
|
Convert a list of protobuf objects to a list of dicts.
|
|
|
|
Args:
|
|
protos: the list of protobuf objects
|
|
|
|
Returns:
|
|
dict_list: the list of dicts
|
|
"""
|
|
return [ProtobufUtil.to_dict(proto) for proto in protos]
|
|
|
|
|
|
class ResourceRequestUtil(ProtobufUtil):
|
|
"""
|
|
A utility class for resource requests, autoscaler.proto.ResourceRequest
|
|
"""
|
|
|
|
class PlacementConstraintType(Enum):
|
|
"""
|
|
The affinity type for the resource request.
|
|
"""
|
|
|
|
ANTI_AFFINITY = "ANTI_AFFINITY"
|
|
AFFINITY = "AFFINITY"
|
|
|
|
@staticmethod
|
|
def group_by_count(
|
|
requests: List[ResourceRequest],
|
|
) -> List[ResourceRequestByCountProto]:
|
|
"""
|
|
Aggregate resource requests by shape.
|
|
Args:
|
|
requests: the list of resource requests
|
|
Returns:
|
|
resource_requests_by_count: the aggregated resource requests by count
|
|
"""
|
|
resource_requests_by_count = defaultdict(int)
|
|
for request in requests:
|
|
serialized_request = request.SerializeToString()
|
|
resource_requests_by_count[serialized_request] += 1
|
|
|
|
results = []
|
|
for serialized_request, count in resource_requests_by_count.items():
|
|
request = ResourceRequest()
|
|
request.ParseFromString(serialized_request)
|
|
results.append(ResourceRequestByCountProto(request=request, count=count))
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def ungroup_by_count(
|
|
requests_by_count: List[ResourceRequestByCountProto],
|
|
) -> List[ResourceRequest]:
|
|
"""
|
|
Flatten the resource requests by count to resource requests.
|
|
Args:
|
|
requests_by_count: the resource requests by count
|
|
Returns:
|
|
requests: the flattened resource requests
|
|
"""
|
|
reqs = []
|
|
for r in requests_by_count:
|
|
reqs += [r.request] * r.count
|
|
|
|
return reqs
|
|
|
|
@staticmethod
|
|
def to_resource_map(
|
|
request: ResourceRequest,
|
|
) -> Dict[str, float]:
|
|
"""
|
|
Convert the resource request by count to resource map.
|
|
Args:
|
|
request: the resource request
|
|
Returns:
|
|
resource_map: the resource map
|
|
"""
|
|
resource_map = defaultdict(float)
|
|
for k, v in request.resources_bundle.items():
|
|
resource_map[k] += v
|
|
return dict(resource_map)
|
|
|
|
@staticmethod
|
|
def to_resource_maps(
|
|
requests: List[ResourceRequest],
|
|
) -> List[Dict[str, float]]:
|
|
"""
|
|
Convert the resource requests by count to resource map.
|
|
Args:
|
|
requests: the resource requests
|
|
Returns:
|
|
resource_maps: list of resource map
|
|
"""
|
|
return [ResourceRequestUtil.to_resource_map(r) for r in requests]
|
|
|
|
@staticmethod
|
|
def make(
|
|
resources_map: Dict[str, float],
|
|
constraints: Optional[List[Tuple[PlacementConstraintType, str, str]]] = None,
|
|
label_selectors: Optional[List[List[Tuple[str, int, List[str]]]]] = None,
|
|
) -> ResourceRequest:
|
|
"""
|
|
Make a resource request from the given resources map.
|
|
Args:
|
|
resources_map: Mapping of resource names to quantities.
|
|
constraints: Placement constraints. Each tuple is (constraint_type,
|
|
label_key, label_value), where `constraint_type` is a
|
|
PlacementConstraintType (AFFINITY or ANTI_AFFINITY).
|
|
label_selectors: Optional list of label selectors. Each selector is
|
|
a list of (label_key, operator_enum, label_values) tuples.
|
|
Returns:
|
|
request: the ResourceRequest object
|
|
"""
|
|
request = ResourceRequest()
|
|
for resource_name, quantity in resources_map.items():
|
|
request.resources_bundle[resource_name] = quantity
|
|
|
|
if constraints is not None:
|
|
for constraint_type, label, value in constraints:
|
|
if (
|
|
constraint_type
|
|
== ResourceRequestUtil.PlacementConstraintType.AFFINITY
|
|
):
|
|
request.placement_constraints.append(
|
|
PlacementConstraint(
|
|
affinity=AffinityConstraint(
|
|
label_name=label, label_value=value
|
|
)
|
|
)
|
|
)
|
|
elif (
|
|
constraint_type
|
|
== ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
|
):
|
|
request.placement_constraints.append(
|
|
PlacementConstraint(
|
|
anti_affinity=AntiAffinityConstraint(
|
|
label_name=label, label_value=value
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown constraint type: {constraint_type}")
|
|
|
|
if label_selectors is not None:
|
|
for selector in label_selectors:
|
|
selector_proto = LabelSelector()
|
|
for label_key, operator_enum, label_values in selector:
|
|
selector_proto.label_constraints.append(
|
|
LabelSelectorConstraint(
|
|
label_key=label_key,
|
|
operator=operator_enum,
|
|
label_values=label_values,
|
|
)
|
|
)
|
|
request.label_selectors.append(selector_proto)
|
|
|
|
return request
|
|
|
|
@staticmethod
|
|
def combine_requests_with_affinity(
|
|
resource_requests: List[ResourceRequest],
|
|
) -> List[ResourceRequest]:
|
|
"""
|
|
Combine the resource requests with affinity constraints
|
|
into the same request. This is so that requests with affinity
|
|
constraints could be considered and placed together.
|
|
|
|
It merges the resource requests with the same affinity constraints
|
|
into one request, and dedup the placement constraints.
|
|
|
|
This assumes following:
|
|
1. There's only at most 1 placement constraint, either an affinity
|
|
constraint OR an anti-affinity constraint.
|
|
|
|
Args:
|
|
resource_requests: The list of resource requests to be combined.
|
|
Returns:
|
|
A list of combined resource requests.
|
|
"""
|
|
|
|
# Map of set of serialized affinity constraint to the list of resource requests
|
|
requests_by_affinity: Dict[
|
|
Tuple[str, str, Tuple], List[ResourceRequest]
|
|
] = defaultdict(list)
|
|
combined_requests: List[ResourceRequest] = []
|
|
|
|
for request in resource_requests:
|
|
assert len(request.placement_constraints) <= 1, (
|
|
"There should be at most 1 placement constraint, "
|
|
"either an affinity constraint OR an anti-affinity constraint."
|
|
)
|
|
|
|
if len(request.placement_constraints) == 0:
|
|
# No affinity constraints, just add to the combined requests.
|
|
combined_requests.append(request)
|
|
continue
|
|
|
|
constraint = request.placement_constraints[0]
|
|
|
|
if constraint.HasField("affinity"):
|
|
# Combine requests with affinity and label selectors.
|
|
affinity = constraint.affinity
|
|
key = (
|
|
affinity.label_name,
|
|
affinity.label_value,
|
|
ResourceRequestUtil._label_selector_key(request.label_selectors),
|
|
)
|
|
requests_by_affinity[key].append(request)
|
|
elif constraint.HasField("anti_affinity"):
|
|
# We don't need to combine requests with anti-affinity constraints.
|
|
combined_requests.append(request)
|
|
|
|
for (
|
|
affinity_label_name,
|
|
affinity_label_value,
|
|
label_selector_key,
|
|
), requests in requests_by_affinity.items():
|
|
combined_request = ResourceRequest()
|
|
|
|
# Merge the resource bundles with the same affinity constraint.
|
|
for request in requests:
|
|
for k, v in request.resources_bundle.items():
|
|
combined_request.resources_bundle[k] = (
|
|
combined_request.resources_bundle.get(k, 0) + v
|
|
)
|
|
|
|
# Add the placement constraint to the combined request.
|
|
affinity_constraint = AffinityConstraint(
|
|
label_name=affinity_label_name, label_value=affinity_label_value
|
|
)
|
|
combined_request.placement_constraints.append(
|
|
PlacementConstraint(affinity=affinity_constraint)
|
|
)
|
|
|
|
combined_request.label_selectors.extend(requests[0].label_selectors)
|
|
|
|
combined_requests.append(combined_request)
|
|
|
|
return combined_requests
|
|
|
|
def _label_selector_key(
|
|
label_selectors: List[LabelSelector],
|
|
) -> Tuple:
|
|
"""
|
|
Convert label selectors into a hashable form for grouping.
|
|
This is used for gang requests with identical label_selectors.
|
|
"""
|
|
result = []
|
|
for selector in label_selectors:
|
|
constraints = []
|
|
for constraint in selector.label_constraints:
|
|
constraints.append(
|
|
(
|
|
constraint.label_key,
|
|
constraint.operator,
|
|
tuple(sorted(constraint.label_values)),
|
|
)
|
|
)
|
|
result.append(tuple(constraints))
|
|
return tuple(result)
|
|
|
|
|
|
class ClusterStatusFormatter:
|
|
"""
|
|
A formatter to format the ClusterStatus into a string.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
def format(cls, data: ClusterStatus, verbose: bool = False) -> str:
|
|
header, separator_len = cls._header_info(data, verbose)
|
|
separator = "-" * separator_len
|
|
|
|
# Parse ClusterStatus information to reportable format
|
|
available_node_report = cls._available_node_report(data)
|
|
idle_node_report = cls._idle_node_report(data)
|
|
pending_report = cls._pending_node_report(data)
|
|
failure_report = cls._failed_node_report(data, verbose)
|
|
cluster_usage_report = cls._cluster_usage_report(data, verbose)
|
|
constraints_report = cls._constraint_report(
|
|
data.resource_demands.cluster_constraint_demand
|
|
)
|
|
demand_report = cls._demand_report(data)
|
|
node_usage_report = (
|
|
""
|
|
if not verbose
|
|
else cls._node_usage_report(data.active_nodes, data.idle_nodes)
|
|
)
|
|
|
|
# Format Cluster Status reports into one output
|
|
formatted_output_lines = [
|
|
header,
|
|
"Node status",
|
|
separator,
|
|
"Active:",
|
|
available_node_report,
|
|
"Idle:",
|
|
idle_node_report,
|
|
"Pending:",
|
|
pending_report,
|
|
failure_report,
|
|
"",
|
|
"Resources",
|
|
separator,
|
|
"Total Usage:",
|
|
cluster_usage_report,
|
|
"From request_resources:",
|
|
constraints_report,
|
|
"Pending Demands:",
|
|
demand_report,
|
|
node_usage_report,
|
|
]
|
|
|
|
formatted_output = "\n".join(formatted_output_lines)
|
|
|
|
return formatted_output.strip()
|
|
|
|
@staticmethod
|
|
def _node_usage_report(
|
|
active_nodes: List[NodeInfo], idle_nodes: List[NodeInfo]
|
|
) -> str:
|
|
"""[Example]:
|
|
Node: raycluster-autoscaler-small-group-worker-n8hrw (small-group)
|
|
Id: cc22041297e5fc153b5357e41f184c8000869e8de97252cc0291fd17
|
|
Usage:
|
|
1.0/1.0 CPU
|
|
0B/953.67MiB memory
|
|
0B/251.76MiB object_store_memory
|
|
Activity:
|
|
Resource: CPU currently in use.
|
|
Busy workers on node.
|
|
"""
|
|
node_id_to_usage: Dict[str, Dict[str, Tuple[float, float]]] = {}
|
|
node_id_to_type: Dict[str, str] = {}
|
|
node_id_to_idle_time: Dict[str, int] = {}
|
|
node_id_to_instance_id: Dict[str, str] = {}
|
|
node_id_to_activities: Dict[str, List[str]] = {}
|
|
|
|
# Populate mappings for node types, idle times, instance ids, and activities
|
|
for node in chain(active_nodes, idle_nodes):
|
|
node_id_to_usage[node.node_id] = {
|
|
u.resource_name: (u.used, u.total) for u in node.resource_usage.usage
|
|
}
|
|
node_id_to_type[node.node_id] = node.ray_node_type_name
|
|
node_id_to_idle_time[node.node_id] = node.resource_usage.idle_time_ms
|
|
node_id_to_instance_id[node.node_id] = node.instance_id
|
|
node_id_to_activities[node.node_id] = node.node_activity
|
|
|
|
node_usage_report_lines = []
|
|
for node_id, usage in node_id_to_usage.items():
|
|
node_usage_report_lines.append("") # Add a blank line between nodes
|
|
|
|
node_type_line = f"Node: {node_id_to_instance_id[node_id]}"
|
|
if node_id in node_id_to_type:
|
|
node_type = node_id_to_type[node_id]
|
|
node_type_line += f" ({node_type})"
|
|
node_usage_report_lines.append(node_type_line)
|
|
node_usage_report_lines.append(f" Id: {node_id}")
|
|
|
|
if node_id_to_idle_time.get(node_id, 0) > 0:
|
|
node_usage_report_lines.append(
|
|
f" Idle: {node_id_to_idle_time[node_id]} ms"
|
|
)
|
|
|
|
node_usage_report_lines.append(" Usage:")
|
|
for line in parse_usage(usage, verbose=True):
|
|
node_usage_report_lines.append(f" {line}")
|
|
|
|
activities = node_id_to_activities.get(node_id, [])
|
|
node_usage_report_lines.append(" Activity:")
|
|
if activities is None or len(activities) == 0:
|
|
node_usage_report_lines.append(" (no activity)")
|
|
else:
|
|
for activity in activities:
|
|
node_usage_report_lines.append(f" {activity}")
|
|
|
|
# Join the list into a single string with new lines
|
|
return "\n".join(node_usage_report_lines)
|
|
|
|
@staticmethod
|
|
def _header_info(data: ClusterStatus, verbose: bool) -> Tuple[str, int]:
|
|
# Get the request timestamp or default to the current time
|
|
time = (
|
|
datetime.fromtimestamp(data.stats.request_ts_s)
|
|
if data.stats.request_ts_s
|
|
else datetime.now()
|
|
)
|
|
|
|
# Gather the time statistics
|
|
gcs_request_time = data.stats.gcs_request_time_s
|
|
non_terminated_nodes_time = data.stats.none_terminated_node_request_time_s
|
|
autoscaler_update_time = data.stats.autoscaler_iteration_time_s
|
|
|
|
# Create the header with autoscaler status
|
|
header = "=" * 8 + f" Autoscaler status: {time} " + "=" * 8
|
|
separator_len = len(header)
|
|
|
|
# Add verbose details if required
|
|
if verbose:
|
|
details = []
|
|
if gcs_request_time:
|
|
details.append(f"GCS request time: {gcs_request_time:3f}s")
|
|
if non_terminated_nodes_time:
|
|
details.append(
|
|
f"Node Provider non_terminated_nodes time: {non_terminated_nodes_time:3f}s"
|
|
)
|
|
if autoscaler_update_time:
|
|
details.append(
|
|
f"Autoscaler iteration time: {autoscaler_update_time:3f}s"
|
|
)
|
|
|
|
if details:
|
|
header += "\n" + "\n".join(details) + "\n"
|
|
|
|
return header, separator_len
|
|
|
|
@staticmethod
|
|
def _available_node_report(data: ClusterStatus) -> str:
|
|
active_nodes = _count_by(data.active_nodes, "ray_node_type_name")
|
|
|
|
# Build the available node report
|
|
if not active_nodes:
|
|
return " (no active nodes)"
|
|
return "\n".join(
|
|
f" {count} {node_type}" for node_type, count in active_nodes.items()
|
|
)
|
|
|
|
@staticmethod
|
|
def _idle_node_report(data: ClusterStatus) -> str:
|
|
idle_nodes = _count_by(data.idle_nodes, "ray_node_type_name")
|
|
|
|
# Build the idle node report
|
|
if not idle_nodes:
|
|
return " (no idle nodes)"
|
|
return "\n".join(
|
|
f" {count} {node_type}" for node_type, count in idle_nodes.items()
|
|
)
|
|
|
|
@staticmethod
|
|
def _failed_node_report(data: ClusterStatus, verbose: bool) -> str:
|
|
failure_lines = []
|
|
|
|
# Process failed launches
|
|
if data.failed_launches:
|
|
sorted_failed_launches = sorted(
|
|
data.failed_launches,
|
|
key=lambda launch: launch.request_ts_s,
|
|
reverse=True,
|
|
)
|
|
|
|
for failed_launch in sorted_failed_launches:
|
|
node_type = failed_launch.ray_node_type_name
|
|
category = "LaunchFailed"
|
|
description = failed_launch.details
|
|
attempted_time = datetime.fromtimestamp(failed_launch.request_ts_s)
|
|
formatted_time = f"{attempted_time.hour:02d}:{attempted_time.minute:02d}:{attempted_time.second:02d}"
|
|
|
|
line = f" {node_type}: {category} (latest_attempt: {formatted_time})"
|
|
if verbose:
|
|
line += f" - {description}"
|
|
|
|
failure_lines.append(line)
|
|
|
|
# Process failed nodes
|
|
for node in data.failed_nodes:
|
|
failure_lines.append(
|
|
f" {node.ray_node_type_name}: NodeTerminated (instance_id: {node.instance_id})"
|
|
)
|
|
|
|
# Limit the number of failures displayed
|
|
failure_lines = failure_lines[: constants.AUTOSCALER_MAX_FAILURES_DISPLAYED]
|
|
|
|
# Build the failure report
|
|
failure_report = "Recent failures:\n"
|
|
failure_report += (
|
|
"\n".join(failure_lines) if failure_lines else " (no failures)"
|
|
)
|
|
|
|
return failure_report
|
|
|
|
@staticmethod
|
|
def _pending_node_report(data: ClusterStatus) -> str:
|
|
# Prepare pending launch lines
|
|
pending_lines = [
|
|
f" {node_type}, {count} launching"
|
|
for node_type, count in _count_by(
|
|
data.pending_launches, "ray_node_type_name"
|
|
).items()
|
|
]
|
|
|
|
# Prepare pending node lines
|
|
pending_lines.extend(
|
|
f" {ip}: {node_type}, {status.lower()}"
|
|
for ip, node_type, status in (
|
|
(node.instance_id, node.ray_node_type_name, node.details)
|
|
for node in data.pending_nodes
|
|
)
|
|
)
|
|
|
|
# Construct the pending report
|
|
if pending_lines:
|
|
return "\n".join(pending_lines)
|
|
return " (no pending nodes)"
|
|
|
|
@staticmethod
|
|
def _constraint_report(
|
|
cluster_constraint_demand: List[ClusterConstraintDemand],
|
|
) -> str:
|
|
"""Returns a formatted string describing the resource constraints from request_resources().
|
|
|
|
Args:
|
|
cluster_constraint_demand: List of cluster constraint demands
|
|
containing resource demand information.
|
|
|
|
Returns:
|
|
String containing the formatted constraints report, either listing each constraint
|
|
and count or indicating no constraints exist.
|
|
|
|
Example:
|
|
>>> cluster_constraint_demand = [
|
|
... ClusterConstraintDemand(bundles_by_count=[
|
|
... ResourceRequestByCount(bundle={"CPU": 4}, count=2),
|
|
... ResourceRequestByCount(bundle={"GPU": 1}, count=1)
|
|
... ])
|
|
... ]
|
|
>>> ClusterStatusFormatter._constraint_report(cluster_constraint_demand)
|
|
" {'CPU': 4}: 2 from request_resources()\\n {'GPU': 1}: 1 from request_resources()"
|
|
"""
|
|
constraint_lines = []
|
|
request_demand = [
|
|
(bc.bundle, bc.count)
|
|
for constraint_demand in cluster_constraint_demand
|
|
for bc in constraint_demand.bundles_by_count
|
|
]
|
|
for bundle, count in request_demand:
|
|
constraint_lines.append(f" {bundle}: {count} from request_resources()")
|
|
if constraint_lines:
|
|
return "\n".join(constraint_lines)
|
|
return " (none)"
|
|
|
|
@staticmethod
|
|
def _demand_report(data: ClusterStatus) -> str:
|
|
# Process resource demands
|
|
resource_demands = [
|
|
(bundle.bundle, bundle.count)
|
|
for demand in data.resource_demands.ray_task_actor_demand
|
|
for bundle in demand.bundles_by_count
|
|
]
|
|
demand_lines = []
|
|
if resource_demands:
|
|
demand_lines.extend(format_resource_demand_summary(resource_demands))
|
|
|
|
# Process placement group demands
|
|
pg_demand_strs = [
|
|
f"{pg_demand.strategy}|{pg_demand.state}"
|
|
for pg_demand in data.resource_demands.placement_group_demand
|
|
]
|
|
pg_demand_str_to_demand = {
|
|
f"{pg_demand.strategy}|{pg_demand.state}": pg_demand
|
|
for pg_demand in data.resource_demands.placement_group_demand
|
|
}
|
|
pg_freqs = Counter(pg_demand_strs)
|
|
|
|
pg_demand = [
|
|
(
|
|
{
|
|
"strategy": pg_demand_str_to_demand[pg_str].strategy,
|
|
"bundles": [
|
|
(bundle.bundle, bundle.count)
|
|
for bundle in pg_demand_str_to_demand[pg_str].bundles_by_count
|
|
],
|
|
},
|
|
freq,
|
|
)
|
|
for pg_str, freq in pg_freqs.items()
|
|
]
|
|
|
|
for pg, count in pg_demand:
|
|
pg_str = format_pg(pg)
|
|
demand_lines.append(f" {pg_str}: {count}+ pending placement groups")
|
|
|
|
# Generate demand report
|
|
if demand_lines:
|
|
return "\n".join(demand_lines)
|
|
return " (no resource demands)"
|
|
|
|
@staticmethod
|
|
def _cluster_usage_report(data: ClusterStatus, verbose: bool) -> str:
|
|
# Build usage dictionary
|
|
usage = {
|
|
u.resource_name: (u.used, u.total) for u in data.cluster_resource_usage
|
|
}
|
|
|
|
# Parse usage lines
|
|
usage_lines = parse_usage(usage, verbose)
|
|
|
|
# Generate usage report
|
|
usage_report = [f" {line}" for line in usage_lines] + [""]
|
|
|
|
return "\n".join(usage_report)
|
|
|
|
|
|
class ClusterStatusParser:
|
|
@classmethod
|
|
def from_get_cluster_status_reply(
|
|
cls, proto: GetClusterStatusReply, stats: Stats
|
|
) -> ClusterStatus:
|
|
# parse nodes info
|
|
active_nodes, idle_nodes, failed_nodes = cls._parse_nodes(
|
|
proto.cluster_resource_state
|
|
)
|
|
|
|
# parse pending nodes info
|
|
pending_nodes = cls._parse_pending(proto.autoscaling_state)
|
|
|
|
# parse launch requests
|
|
pending_launches, failed_launches = cls._parse_launch_requests(
|
|
proto.autoscaling_state
|
|
)
|
|
|
|
# parse cluster resource usage
|
|
cluster_resource_usage = cls._parse_cluster_resource_usage(
|
|
proto.cluster_resource_state
|
|
)
|
|
|
|
# parse resource demands
|
|
resource_demands = cls._parse_resource_demands(proto.cluster_resource_state)
|
|
|
|
# parse stats
|
|
stats = cls._parse_stats(proto, stats)
|
|
|
|
return ClusterStatus(
|
|
active_nodes=active_nodes,
|
|
idle_nodes=idle_nodes,
|
|
pending_launches=pending_launches,
|
|
failed_launches=failed_launches,
|
|
pending_nodes=pending_nodes,
|
|
failed_nodes=failed_nodes,
|
|
cluster_resource_usage=cluster_resource_usage,
|
|
resource_demands=resource_demands,
|
|
stats=stats,
|
|
)
|
|
|
|
@classmethod
|
|
def _parse_stats(cls, reply: GetClusterStatusReply, stats: Stats) -> Stats:
|
|
"""
|
|
Parse the stats from the get cluster status reply.
|
|
Args:
|
|
reply: the get cluster status reply
|
|
stats: the stats
|
|
Returns:
|
|
stats: the parsed stats
|
|
"""
|
|
stats = deepcopy(stats)
|
|
|
|
stats.gcs_request_time_s = stats.gcs_request_time_s
|
|
# TODO(rickyx): Populate other autoscaler stats once available.
|
|
stats.autoscaler_version = str(reply.autoscaling_state.autoscaler_state_version)
|
|
stats.cluster_resource_state_version = str(
|
|
reply.cluster_resource_state.cluster_resource_state_version
|
|
)
|
|
|
|
return stats
|
|
|
|
@classmethod
|
|
def _parse_resource_demands(
|
|
cls, state: ClusterResourceState
|
|
) -> List[ResourceDemand]:
|
|
"""
|
|
Parse the resource demands from the cluster resource state.
|
|
Args:
|
|
state: the cluster resource state
|
|
Returns:
|
|
resource_demands: the resource demands
|
|
"""
|
|
task_actor_demand = []
|
|
pg_demand = []
|
|
constraint_demand = []
|
|
|
|
for request_count in state.pending_resource_requests:
|
|
# TODO(rickyx): constraints?
|
|
demand = RayTaskActorDemand(
|
|
bundles_by_count=[
|
|
ResourceRequestByCount(
|
|
request_count.request.resources_bundle, request_count.count
|
|
)
|
|
],
|
|
)
|
|
task_actor_demand.append(demand)
|
|
|
|
for gang_request in state.pending_gang_resource_requests:
|
|
demand = PlacementGroupResourceDemand(
|
|
bundles_by_count=cls._aggregate_resource_requests_by_shape(
|
|
gang_request.requests
|
|
),
|
|
details=gang_request.details,
|
|
)
|
|
pg_demand.append(demand)
|
|
|
|
for constraint_request in state.cluster_resource_constraints:
|
|
demand = ClusterConstraintDemand(
|
|
bundles_by_count=[
|
|
ResourceRequestByCount(
|
|
bundle=dict(r.request.resources_bundle.items()), count=r.count
|
|
)
|
|
for r in constraint_request.resource_requests
|
|
]
|
|
)
|
|
constraint_demand.append(demand)
|
|
|
|
return ResourceDemandSummary(
|
|
ray_task_actor_demand=task_actor_demand,
|
|
placement_group_demand=pg_demand,
|
|
cluster_constraint_demand=constraint_demand,
|
|
)
|
|
|
|
@classmethod
|
|
def _aggregate_resource_requests_by_shape(
|
|
cls,
|
|
requests: List[ResourceRequest],
|
|
) -> List[ResourceRequestByCount]:
|
|
"""
|
|
Aggregate resource requests by shape.
|
|
Args:
|
|
requests: the list of resource requests
|
|
Returns:
|
|
resource_requests_by_count: the aggregated resource requests by count
|
|
"""
|
|
|
|
resource_requests_by_count = defaultdict(int)
|
|
for request in requests:
|
|
bundle = frozenset(request.resources_bundle.items())
|
|
resource_requests_by_count[bundle] += 1
|
|
|
|
return [
|
|
ResourceRequestByCount(dict(bundle), count)
|
|
for bundle, count in resource_requests_by_count.items()
|
|
]
|
|
|
|
@classmethod
|
|
def _parse_node_resource_usage(
|
|
cls, node_state: NodeState, usage: Dict[str, ResourceUsage]
|
|
) -> Dict[str, ResourceUsage]:
|
|
"""
|
|
Parse the node resource usage from the node state.
|
|
Args:
|
|
node_state: the node state
|
|
usage: the usage dict to be updated. This is a dict of
|
|
{resource_name: ResourceUsage}
|
|
Returns:
|
|
usage: the updated usage dict
|
|
"""
|
|
# Tuple of {resource_name : (used, total)}
|
|
d = defaultdict(lambda: [0.0, 0.0])
|
|
for resource_name, resource_total in node_state.total_resources.items():
|
|
d[resource_name][1] += resource_total
|
|
# Will be subtracted from available later.
|
|
d[resource_name][0] += resource_total
|
|
|
|
for (
|
|
resource_name,
|
|
resource_available,
|
|
) in node_state.available_resources.items():
|
|
d[resource_name][0] -= resource_available
|
|
|
|
# Merge with the passed in usage.
|
|
for k, (used, total) in d.items():
|
|
usage[k].resource_name = k
|
|
usage[k].used += used
|
|
usage[k].total += total
|
|
|
|
return usage
|
|
|
|
@classmethod
|
|
def _parse_cluster_resource_usage(
|
|
cls,
|
|
state: ClusterResourceState,
|
|
) -> List[ResourceUsage]:
|
|
"""
|
|
Parse the cluster resource usage from the cluster resource state.
|
|
Args:
|
|
state: the cluster resource state
|
|
Returns:
|
|
cluster_resource_usage: the cluster resource usage
|
|
"""
|
|
|
|
cluster_resource_usage = defaultdict(ResourceUsage)
|
|
|
|
for node_state in state.node_states:
|
|
if node_state.status != NodeStatus.DEAD:
|
|
cluster_resource_usage = cls._parse_node_resource_usage(
|
|
node_state, cluster_resource_usage
|
|
)
|
|
|
|
return list(cluster_resource_usage.values())
|
|
|
|
@classmethod
|
|
def _parse_nodes(
|
|
cls,
|
|
state: ClusterResourceState,
|
|
) -> Tuple[List[NodeInfo], List[NodeInfo]]:
|
|
"""
|
|
Parse the node info from the cluster resource state.
|
|
Args:
|
|
state: the cluster resource state
|
|
Returns:
|
|
active_nodes: the list of non-idle nodes
|
|
idle_nodes: the list of idle nodes
|
|
dead_nodes: the list of dead nodes
|
|
"""
|
|
active_nodes = []
|
|
dead_nodes = []
|
|
idle_nodes = []
|
|
for node_state in state.node_states:
|
|
# Basic node info.
|
|
node_id = binary_to_hex(node_state.node_id)
|
|
if len(node_state.ray_node_type_name) == 0:
|
|
# We don't have a node type name, but this is needed for showing
|
|
# healthy nodes. This happens when we don't use cluster launcher.
|
|
# but start ray manually. We will use node id as node type name.
|
|
ray_node_type_name = f"node_{node_id}"
|
|
else:
|
|
ray_node_type_name = node_state.ray_node_type_name
|
|
|
|
# Parse the resource usage if it's not dead
|
|
node_resource_usage = None
|
|
failure_detail = None
|
|
if node_state.status == NodeStatus.DEAD:
|
|
# TODO(rickyx): Technically we could get a more verbose
|
|
# failure detail from GCS, but existing ray status treats
|
|
# all ray failures as raylet death.
|
|
failure_detail = NODE_DEATH_CAUSE_RAYLET_DIED
|
|
else:
|
|
usage = defaultdict(ResourceUsage)
|
|
usage = cls._parse_node_resource_usage(node_state, usage)
|
|
node_resource_usage = NodeUsage(
|
|
usage=list(usage.values()),
|
|
idle_time_ms=node_state.idle_duration_ms
|
|
if node_state.status == NodeStatus.IDLE
|
|
else 0,
|
|
)
|
|
|
|
node_info = NodeInfo(
|
|
instance_type_name=node_state.instance_type_name,
|
|
node_status=NodeStatus.Name(node_state.status),
|
|
node_id=binary_to_hex(node_state.node_id),
|
|
ip_address=node_state.node_ip_address,
|
|
ray_node_type_name=ray_node_type_name,
|
|
instance_id=node_state.instance_id,
|
|
resource_usage=node_resource_usage,
|
|
failure_detail=failure_detail,
|
|
node_activity=node_state.node_activity,
|
|
labels=dict(node_state.labels),
|
|
)
|
|
|
|
if node_state.status == NodeStatus.DEAD:
|
|
dead_nodes.append(node_info)
|
|
elif node_state.status == NodeStatus.IDLE:
|
|
idle_nodes.append(node_info)
|
|
else:
|
|
active_nodes.append(node_info)
|
|
|
|
return active_nodes, idle_nodes, dead_nodes
|
|
|
|
@classmethod
|
|
def _parse_launch_requests(
|
|
cls, state: AutoscalingState
|
|
) -> Tuple[List[LaunchRequest], List[LaunchRequest]]:
|
|
"""
|
|
Parse the launch requests from the autoscaling state.
|
|
Args:
|
|
state: the autoscaling state, empty if there's no autoscaling state
|
|
being reported.
|
|
Returns:
|
|
pending_launches: the list of pending launches
|
|
failed_launches: the list of failed launches
|
|
"""
|
|
pending_launches = []
|
|
for pending_request in state.pending_instance_requests:
|
|
launch = LaunchRequest(
|
|
instance_type_name=pending_request.instance_type_name,
|
|
ray_node_type_name=pending_request.ray_node_type_name,
|
|
count=pending_request.count,
|
|
state=LaunchRequest.Status.PENDING,
|
|
request_ts_s=pending_request.request_ts,
|
|
)
|
|
|
|
pending_launches.append(launch)
|
|
|
|
failed_launches = []
|
|
for failed_request in state.failed_instance_requests:
|
|
launch = LaunchRequest(
|
|
instance_type_name=failed_request.instance_type_name,
|
|
ray_node_type_name=failed_request.ray_node_type_name,
|
|
count=failed_request.count,
|
|
state=LaunchRequest.Status.FAILED,
|
|
request_ts_s=failed_request.start_ts,
|
|
details=failed_request.reason,
|
|
failed_ts_s=failed_request.failed_ts,
|
|
)
|
|
|
|
failed_launches.append(launch)
|
|
|
|
return pending_launches, failed_launches
|
|
|
|
@classmethod
|
|
def _parse_pending(cls, state: AutoscalingState) -> List[NodeInfo]:
|
|
"""
|
|
Parse the pending requests/nodes from the autoscaling state.
|
|
Args:
|
|
state: the autoscaling state, empty if there's no autoscaling state
|
|
being reported.
|
|
Returns:
|
|
pending_nodes: the list of pending nodes
|
|
"""
|
|
pending_nodes = []
|
|
for pending_node in state.pending_instances:
|
|
pending_nodes.append(
|
|
NodeInfo(
|
|
instance_type_name=pending_node.instance_type_name,
|
|
ray_node_type_name=pending_node.ray_node_type_name,
|
|
details=pending_node.details,
|
|
instance_id=pending_node.instance_id,
|
|
ip_address=pending_node.ip_address,
|
|
)
|
|
)
|
|
|
|
return pending_nodes
|
|
|
|
|
|
cached_is_autoscaler_v2 = None
|
|
|
|
|
|
def is_autoscaler_v2(
|
|
fetch_from_server: bool = False, gcs_client: Optional[GcsClient] = None
|
|
) -> bool:
|
|
"""
|
|
Check if the autoscaler is v2 from reading GCS internal KV.
|
|
|
|
If the method is called multiple times, the result will be cached in the module.
|
|
|
|
Args:
|
|
fetch_from_server: If True, fetch the value from the GCS server, otherwise
|
|
use the cached value.
|
|
gcs_client: The GCS client to use. If not provided, the default GCS
|
|
client will be used.
|
|
|
|
Returns:
|
|
is_v2: True if the autoscaler is v2, False otherwise.
|
|
|
|
Raises:
|
|
Exception: if GCS address could not be resolved (e.g. ray.init() not called)
|
|
"""
|
|
# If env var is set to enable autoscaler v2, we should always return True.
|
|
if ray._config.enable_autoscaler_v2() and not fetch_from_server:
|
|
# TODO(rickyx): Once we migrate completely to v2, we should remove this.
|
|
# While this short-circuit may allow client-server inconsistency
|
|
# (e.g. client running v1, while server running v2), it's currently
|
|
# not possible with existing use-cases.
|
|
return True
|
|
|
|
global cached_is_autoscaler_v2
|
|
if cached_is_autoscaler_v2 is not None and not fetch_from_server:
|
|
return cached_is_autoscaler_v2
|
|
|
|
if gcs_client is None:
|
|
gcs_client = internal_kv_get_gcs_client()
|
|
|
|
assert gcs_client, (
|
|
"GCS client is not available. Please initialize the global GCS client "
|
|
"first by calling ray.init() or explicitly calls to _initialize_internal_kv()."
|
|
)
|
|
|
|
# See src/ray/common/constants.h for the definition of this key.
|
|
cached_is_autoscaler_v2 = (
|
|
gcs_client.internal_kv_get(
|
|
ray._raylet.GCS_AUTOSCALER_V2_ENABLED_KEY.encode(),
|
|
namespace=ray._raylet.GCS_AUTOSCALER_STATE_NAMESPACE.encode(),
|
|
)
|
|
== b"1"
|
|
)
|
|
|
|
return cached_is_autoscaler_v2
|
|
|
|
|
|
def is_head_node(node_state: NodeState) -> bool:
|
|
"""
|
|
Check if the node is a head node from the node state.
|
|
|
|
Args:
|
|
node_state: the node state
|
|
Returns:
|
|
is_head: True if the node is a head node, False otherwise.
|
|
"""
|
|
# TODO: we should include this bit of information in the future.
|
|
# NOTE: we could use labels in the future to determine if it's a head node.
|
|
return "node:__internal_head__" in dict(node_state.total_resources)
|