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

384 lines
14 KiB
Python

import logging
import time
from collections import Counter
from functools import reduce
from typing import Any, Callable, Dict, List, Optional
from ray._private.gcs_utils import PlacementGroupTableData
from ray.autoscaler._private.constants import (
AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE,
AUTOSCALER_REPORT_PER_NODE_STATUS,
)
from ray.autoscaler._private.util import (
DictCount,
LoadMetricsSummary,
NodeIP,
ResourceDict,
)
from ray.core.generated.common_pb2 import PlacementStrategy
logger = logging.getLogger(__name__)
def add_resources(dict1: Dict[str, float], dict2: Dict[str, float]) -> Dict[str, float]:
"""Add the values in two dictionaries.
Args:
dict1: The first dictionary.
dict2: The second dictionary.
Returns:
A new dictionary (inputs remain unmodified).
"""
new_dict = dict1.copy()
for k, v in dict2.items():
new_dict[k] = v + new_dict.get(k, 0)
return new_dict
def freq_of_dicts(
dicts: List[Dict],
serializer: Optional[Callable[[Dict], Any]] = None,
deserializer: Callable[[Any], Any] = dict,
) -> DictCount:
"""Count a list of dictionaries (or unhashable types).
This is somewhat annoying because mutable data structures aren't hashable,
and set/dict keys must be hashable.
Args:
dicts: A list of dictionaries to be counted.
serializer: A custom serialization function. The output type
must be hashable. The default serializer converts a dictionary into
a frozenset of KV pairs.
deserializer: A custom deserialization function. See the
serializer for information about the intermediate type. For
dictionaries the output type is the same as the input type.
Returns:
A list of tuples. Each entry in the list is a tuple containing a unique
entry from `dicts` and its corresponding frequency count.
"""
if serializer is None:
serializer = lambda d: frozenset(d.items()) # noqa: E731
freqs = Counter(serializer(d) for d in dicts)
as_list = []
for as_set, count in freqs.items():
as_list.append((deserializer(as_set), count))
return as_list
class LoadMetrics:
"""Container for cluster load metrics.
Metrics here are updated from raylet heartbeats. The autoscaler
queries these metrics to determine when to scale up, and which nodes
can be removed.
"""
def __init__(self):
self.last_heartbeat_time_by_ip = {}
self.static_resources_by_ip = {}
self.dynamic_resources_by_ip = {}
self.node_id_by_ip = {}
self.waiting_bundles = []
self.infeasible_bundles = []
self.pending_placement_groups = []
self.resource_requests = []
self.ray_nodes_last_used_time_by_ip = {}
def __bool__(self):
"""A load metrics instance is Falsey iff the autoscaler process
has not received a resource message from the GCS.
"""
return bool(self.node_id_by_ip)
def update(
self,
ip: str,
node_id: bytes,
static_resources: Dict[str, Dict],
dynamic_resources: Dict[str, Dict],
node_idle_duration_s: float,
waiting_bundles: List[Dict[str, float]] = None,
infeasible_bundles: List[Dict[str, float]] = None,
pending_placement_groups: List[PlacementGroupTableData] = None,
):
self.static_resources_by_ip[ip] = static_resources
self.node_id_by_ip[ip] = node_id
if not waiting_bundles:
waiting_bundles = []
if not infeasible_bundles:
infeasible_bundles = []
if not pending_placement_groups:
pending_placement_groups = []
# We are not guaranteed to have a corresponding dynamic resource
# for every static resource because dynamic resources are based on
# the available resources in the heartbeat, which does not exist
# if it is zero. Thus, we have to update dynamic resources here.
dynamic_resources_update = dynamic_resources.copy()
for resource_name, capacity in self.static_resources_by_ip[ip].items():
if resource_name not in dynamic_resources_update:
dynamic_resources_update[resource_name] = 0.0
self.dynamic_resources_by_ip[ip] = dynamic_resources_update
now = time.time()
self.ray_nodes_last_used_time_by_ip[ip] = now - node_idle_duration_s
self.last_heartbeat_time_by_ip[ip] = now
self.waiting_bundles = waiting_bundles
self.infeasible_bundles = infeasible_bundles
self.pending_placement_groups = pending_placement_groups
def mark_active(self, ip):
assert ip is not None, "IP should be known at this time"
logger.debug("Node {} is newly setup, treating as active".format(ip))
self.last_heartbeat_time_by_ip[ip] = time.time()
def prune_active_ips(self, active_ips: List[str]):
"""The Raylet ips stored by LoadMetrics are obtained by polling
the GCS in Monitor.update_load_metrics().
On the other hand, the autoscaler gets a list of node ips from
its NodeProvider.
This method removes from LoadMetrics the ips unknown to the autoscaler.
Args:
active_ips: The node ips known to the autoscaler.
"""
active_ips = set(active_ips)
def prune(mapping, should_log):
unwanted_ips = set(mapping) - active_ips
for unwanted_ip in unwanted_ips:
if should_log:
logger.info("LoadMetrics: " f"Removed ip: {unwanted_ip}.")
del mapping[unwanted_ip]
if unwanted_ips and should_log:
logger.info(
"LoadMetrics: "
"Removed {} stale ip mappings: {} not in {}".format(
len(unwanted_ips), unwanted_ips, active_ips
)
)
assert not (unwanted_ips & set(mapping))
prune(self.ray_nodes_last_used_time_by_ip, should_log=True)
prune(self.static_resources_by_ip, should_log=False)
prune(self.node_id_by_ip, should_log=False)
prune(self.dynamic_resources_by_ip, should_log=False)
prune(self.last_heartbeat_time_by_ip, should_log=False)
def get_node_resources(self):
"""Return a list of node resources (static resource sizes).
Returns:
An iterable of node resource dicts.
Example:
>>> from ray.autoscaler._private.load_metrics import LoadMetrics
>>> metrics = LoadMetrics(...) # doctest: +SKIP
>>> metrics.get_node_resources() # doctest: +SKIP
[{"CPU": 1}, {"CPU": 4, "GPU": 8}] # for two different nodes
"""
return self.static_resources_by_ip.values()
def get_static_node_resources_by_ip(self) -> Dict[NodeIP, ResourceDict]:
"""Return a dict of node resources for every node ip.
Returns:
A mapping from node IP to its static resource dict.
Example:
>>> from ray.autoscaler._private.load_metrics import LoadMetrics
>>> metrics = LoadMetrics(...) # doctest: +SKIP
>>> metrics.get_static_node_resources_by_ip() # doctest: +SKIP
{127.0.0.1: {"CPU": 1}, 127.0.0.2: {"CPU": 4, "GPU": 8}}
"""
return self.static_resources_by_ip
def get_resource_utilization(self):
return self.dynamic_resources_by_ip
def _get_resource_usage(self):
resources_used = {}
resources_total = {}
for ip, max_resources in self.static_resources_by_ip.items():
avail_resources = self.dynamic_resources_by_ip[ip]
for resource_id, amount in max_resources.items():
used = amount - avail_resources[resource_id]
if resource_id not in resources_used:
resources_used[resource_id] = 0.0
resources_total[resource_id] = 0.0
resources_used[resource_id] += used
resources_total[resource_id] += amount
used = max(0, used)
return resources_used, resources_total
def get_resource_demand_vector(self, clip=True):
if clip:
# Bound the total number of bundles to
# 2xMAX_RESOURCE_DEMAND_VECTOR_SIZE. This guarantees the resource
# demand scheduler bin packing algorithm takes a reasonable amount
# of time to run.
return (
self.waiting_bundles[:AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE]
+ self.infeasible_bundles[:AUTOSCALER_MAX_RESOURCE_DEMAND_VECTOR_SIZE]
)
else:
return self.waiting_bundles + self.infeasible_bundles
def get_resource_requests(self):
return self.resource_requests
def get_pending_placement_groups(self):
return self.pending_placement_groups
def resources_avail_summary(self) -> str:
"""Return a concise string of cluster size to report to event logs.
For example, "3 CPUs, 4 GPUs".
"""
total_resources = (
reduce(add_resources, self.static_resources_by_ip.values())
if self.static_resources_by_ip
else {}
)
out = "{} CPUs".format(int(total_resources.get("CPU", 0)))
if "GPU" in total_resources:
out += ", {} GPUs".format(int(total_resources["GPU"]))
if "TPU" in total_resources:
out += ", {} TPUs".format(int(total_resources["TPU"]))
return out
def summary(self):
available_resources = (
reduce(add_resources, self.dynamic_resources_by_ip.values())
if self.dynamic_resources_by_ip
else {}
)
total_resources = (
reduce(add_resources, self.static_resources_by_ip.values())
if self.static_resources_by_ip
else {}
)
usage_dict = {}
for key in total_resources:
if key in ["memory", "object_store_memory"]:
total = total_resources[key]
available = available_resources[key]
usage_dict[key] = (total - available, total)
else:
total = total_resources[key]
usage_dict[key] = (total - available_resources[key], total)
summarized_demand_vector = freq_of_dicts(
self.get_resource_demand_vector(clip=False)
)
summarized_resource_requests = freq_of_dicts(self.get_resource_requests())
def placement_group_serializer(pg):
bundles = tuple(
frozenset(bundle.unit_resources.items()) for bundle in pg.bundles
)
return (bundles, pg.strategy)
def placement_group_deserializer(pg_tuple):
# We marshal this as a dictionary so that we can easily json.dumps
# it later.
# TODO (Alex): Would there be a benefit to properly
# marshalling this (into a protobuf)?
bundles = list(map(dict, pg_tuple[0]))
return {
"bundles": freq_of_dicts(bundles),
"strategy": PlacementStrategy.Name(pg_tuple[1]),
}
summarized_placement_groups = freq_of_dicts(
self.get_pending_placement_groups(),
serializer=placement_group_serializer,
deserializer=placement_group_deserializer,
)
nodes_summary = freq_of_dicts(self.static_resources_by_ip.values())
usage_by_node = None
if AUTOSCALER_REPORT_PER_NODE_STATUS:
usage_by_node = {}
for ip, totals in self.static_resources_by_ip.items():
available = self.dynamic_resources_by_ip.get(ip, {})
usage_by_node[ip] = {}
for resource, total in totals.items():
usage_by_node[ip][resource] = (
total - available.get(resource, 0),
total,
)
return LoadMetricsSummary(
usage=usage_dict,
resource_demand=summarized_demand_vector,
pg_demand=summarized_placement_groups,
request_demand=summarized_resource_requests,
node_types=nodes_summary,
usage_by_node=usage_by_node,
)
def set_resource_requests(self, requested_resources):
if requested_resources is not None:
assert isinstance(requested_resources, list), requested_resources
self.resource_requests = [
request for request in requested_resources if len(request) > 0
]
def info_string(self):
return " - " + "\n - ".join(
["{}: {}".format(k, v) for k, v in sorted(self._info().items())]
)
def _info(self):
resources_used, resources_total = self._get_resource_usage()
now = time.time()
idle_times = [now - t for t in self.ray_nodes_last_used_time_by_ip.values()]
heartbeat_times = [now - t for t in self.last_heartbeat_time_by_ip.values()]
most_delayed_heartbeats = sorted(
self.last_heartbeat_time_by_ip.items(), key=lambda pair: pair[1]
)[:5]
most_delayed_heartbeats = {ip: (now - t) for ip, t in most_delayed_heartbeats}
def format_resource(key, value):
if key in ["object_store_memory", "memory"]:
return "{} GiB".format(round(value / (1024 * 1024 * 1024), 2))
else:
return round(value, 2)
return {
"ResourceUsage": ", ".join(
[
"{}/{} {}".format(
format_resource(rid, resources_used[rid]),
format_resource(rid, resources_total[rid]),
rid,
)
for rid in sorted(resources_used)
if not rid.startswith("node:")
]
),
"NodeIdleSeconds": "Min={} Mean={} Max={}".format(
int(min(idle_times)) if idle_times else -1,
int(float(sum(idle_times)) / len(idle_times)) if idle_times else -1,
int(max(idle_times)) if idle_times else -1,
),
"TimeSinceLastHeartbeat": "Min={} Mean={} Max={}".format(
int(min(heartbeat_times)) if heartbeat_times else -1,
int(float(sum(heartbeat_times)) / len(heartbeat_times))
if heartbeat_times
else -1,
int(max(heartbeat_times)) if heartbeat_times else -1,
),
"MostDelayedHeartbeats": most_delayed_heartbeats,
}