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

1023 lines
40 KiB
Python

"""Implements multi-node-type autoscaling.
This file implements an autoscaling algorithm that is aware of multiple node
types (e.g., example-multi-node-type.yaml). The Ray autoscaler will pass in
a vector of resource shape demands, and the resource demand scheduler will
return a list of node types that can satisfy the demands given constraints
(i.e., reverse bin packing).
"""
import collections
import copy
import logging
import os
from abc import abstractmethod
from functools import partial
from typing import Callable, Dict, List, Optional, Tuple
import ray
from ray._private.gcs_utils import PlacementGroupTableData
from ray.autoscaler._private.constants import (
AUTOSCALER_CONSERVE_GPU_NODES,
AUTOSCALER_UPSCALING_INITIAL_NUM_NODES,
AUTOSCALER_UTILIZATION_SCORER_KEY,
)
from ray.autoscaler._private.loader import load_function_or_class
from ray.autoscaler._private.node_provider_availability_tracker import (
NodeAvailabilitySummary,
)
from ray.autoscaler._private.util import (
NodeID,
NodeIP,
NodeType,
NodeTypeConfigDict,
ResourceDict,
is_placement_group_resource,
)
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_UNMANAGED,
NODE_KIND_WORKER,
TAG_RAY_NODE_KIND,
TAG_RAY_USER_NODE_TYPE,
)
from ray.core.generated.common_pb2 import PlacementStrategy
logger = logging.getLogger(__name__)
NodeResources = ResourceDict
ResourceDemands = List[ResourceDict]
class UtilizationScore:
"""This fancy class just defines the `UtilizationScore` protocol to be
some type that is a "totally ordered set" (i.e. things that can be sorted).
What we're really trying to express is
```
UtilizationScore = TypeVar("UtilizationScore", bound=Comparable["UtilizationScore"])
```
but Comparable isn't a real type and, and a bound with a type argument
can't be enforced (f-bounded polymorphism with contravariance). See Guido's
comment for more details: https://github.com/python/typing/issues/59.
This isn't just a `float`. In the case of the default scorer, it's a
`Tuple[float, float]` which is quite difficult to map to a single number.
"""
@abstractmethod
def __eq__(self, other: "UtilizationScore") -> bool:
pass
@abstractmethod
def __lt__(self: "UtilizationScore", other: "UtilizationScore") -> bool:
pass
def __gt__(self: "UtilizationScore", other: "UtilizationScore") -> bool:
return (not self < other) and self != other
def __le__(self: "UtilizationScore", other: "UtilizationScore") -> bool:
return self < other or self == other
def __ge__(self: "UtilizationScore", other: "UtilizationScore") -> bool:
return not self < other
class UtilizationScorer:
def __call__(
node_resources: NodeResources,
resource_demands: ResourceDemands,
*,
node_availability_summary: NodeAvailabilitySummary,
) -> Optional[UtilizationScore]:
pass
class ResourceDemandScheduler:
def __init__(
self,
provider: NodeProvider,
node_types: Dict[NodeType, NodeTypeConfigDict],
max_workers: int,
head_node_type: NodeType,
upscaling_speed: float,
) -> None:
self.provider = provider
self.node_types = copy.deepcopy(node_types)
self.node_resource_updated = set()
self.max_workers = max_workers
self.head_node_type = head_node_type
self.upscaling_speed = upscaling_speed
utilization_scorer_str = os.environ.get(
AUTOSCALER_UTILIZATION_SCORER_KEY,
"ray.autoscaler._private.resource_demand_scheduler"
"._default_utilization_scorer",
)
self.utilization_scorer: UtilizationScorer = load_function_or_class(
utilization_scorer_str
)
def _get_head_and_workers(self, nodes: List[NodeID]) -> Tuple[NodeID, List[NodeID]]:
"""Returns the head node's id and the list of all worker node ids,
given a list `nodes` of all node ids in the cluster.
"""
head_id, worker_ids = None, []
for node in nodes:
tags = self.provider.node_tags(node)
if tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD:
head_id = node
elif tags[TAG_RAY_NODE_KIND] == NODE_KIND_WORKER:
worker_ids.append(node)
return head_id, worker_ids
def reset_config(
self,
provider: NodeProvider,
node_types: Dict[NodeType, NodeTypeConfigDict],
max_workers: int,
head_node_type: NodeType,
upscaling_speed: float = 1,
) -> None:
"""Updates the class state variables.
For legacy yamls, it merges previous state and new state to make sure
inferred resources are not lost.
"""
self.provider = provider
self.node_types = copy.deepcopy(node_types)
self.node_resource_updated = set()
self.max_workers = max_workers
self.head_node_type = head_node_type
self.upscaling_speed = upscaling_speed
def is_feasible(self, bundle: ResourceDict) -> bool:
for node_type, config in self.node_types.items():
max_of_type = config.get("max_workers", 0)
node_resources = config["resources"]
if (node_type == self.head_node_type or max_of_type > 0) and _fits(
node_resources, bundle
):
return True
return False
def get_nodes_to_launch(
self,
nodes: List[NodeID],
launching_nodes: Dict[NodeType, int],
resource_demands: List[ResourceDict],
unused_resources_by_ip: Dict[NodeIP, ResourceDict],
pending_placement_groups: List[PlacementGroupTableData],
max_resources_by_ip: Dict[NodeIP, ResourceDict],
ensure_min_cluster_size: List[ResourceDict],
node_availability_summary: NodeAvailabilitySummary,
) -> Tuple[Dict[NodeType, int], List[ResourceDict]]:
"""Given resource demands, return node types to add to the cluster.
This method:
(1) calculates the resources present in the cluster by:
- computing available resources for each existing node
- counting the number of nodes per node type
- including both running and launching nodes
(2) calculates the remaining nodes to add to respect min_workers
constraint per node type.
(3) for each strict spread placement group, reserve space on
available nodes and launch new nodes if necessary.
(4) calculates the unfulfilled resource bundles.
(5) calculates which nodes need to be launched to fulfill all
the bundle requests, subject to max_worker constraints.
Args:
nodes: List of existing nodes in the cluster.
launching_nodes: Summary of node types currently being launched.
resource_demands: Vector of resource demands from the scheduler.
unused_resources_by_ip: Mapping from ip to available resources.
pending_placement_groups: Placement group demands.
max_resources_by_ip: Mapping from ip to static node resources.
ensure_min_cluster_size: Try to ensure the cluster can fit at least
this set of resources. This differs from resources_demands in
that we don't take into account existing usage.
node_availability_summary: A snapshot of the current
NodeAvailabilitySummary.
Returns:
Dict of count to add for each node type, and residual of resources
that still cannot be fulfilled.
"""
utilization_scorer = partial(
self.utilization_scorer, node_availability_summary=node_availability_summary
)
self._update_node_resources_from_runtime(nodes, max_resources_by_ip)
# Step 1: Calculate current cluster resources and node type counts
node_resources: List[ResourceDict]
node_type_counts: Dict[NodeType, int]
node_resources, node_type_counts = self.calculate_node_resources(
nodes, launching_nodes, unused_resources_by_ip
)
logger.debug("Cluster resources: {}".format(node_resources))
logger.debug("Node counts: {}".format(node_type_counts))
# Step 2: add nodes to add to satisfy min_workers for each type
(
node_resources,
node_type_counts,
adjusted_min_workers,
) = _add_min_workers_nodes(
node_resources,
node_type_counts,
self.node_types,
self.max_workers,
self.head_node_type,
ensure_min_cluster_size,
utilization_scorer=utilization_scorer,
)
# Step 3: get resource demands of placement groups and return the
# groups that should be strictly spread.
logger.debug(f"Placement group demands: {pending_placement_groups}")
# TODO(Clark): Refactor placement group bundle demands such that their placement
# group provenance is mantained, since we need to keep an accounting of the
# cumulative CPU cores allocated as fulfilled during bin packing in order to
# ensure that a placement group's cumulative allocation is under the placement
# group's max CPU fraction per node. Without this, and placement group with many
# bundles might not be schedulable, but will fail to trigger scale-up since the
# max CPU fraction is properly applied to the cumulative bundle requests for a
# single node.
#
# placement_group_demand_vector: List[Tuple[List[ResourceDict], double]]
#
# bin_pack_residual() can keep it's packing priority; we just need to account
# for (1) the running CPU allocation for the bundle's placement group for that
# particular node, and (2) the max CPU cores allocatable for a single placement
# group for that particular node.
(
placement_group_demand_vector,
strict_spreads,
) = placement_groups_to_resource_demands(pending_placement_groups)
# Place placement groups demand vector at the beginning of the resource
# demands vector to make it consistent (results in the same types of
# nodes to add) with pg_demands_nodes_max_launch_limit calculated later
resource_demands = placement_group_demand_vector + resource_demands
(
spread_pg_nodes_to_add,
node_resources,
node_type_counts,
) = self.reserve_and_allocate_spread(
strict_spreads,
node_resources,
node_type_counts,
utilization_scorer,
)
# Calculate the nodes to add for bypassing max launch limit for
# placement groups and spreads.
unfulfilled_placement_groups_demands, _ = get_bin_pack_residual(
node_resources,
placement_group_demand_vector,
)
# Add 1 to account for the head node.
max_to_add = self.max_workers + 1 - sum(node_type_counts.values())
pg_demands_nodes_max_launch_limit, _ = get_nodes_for(
self.node_types,
node_type_counts,
self.head_node_type,
max_to_add,
unfulfilled_placement_groups_demands,
utilization_scorer=utilization_scorer,
)
placement_groups_nodes_max_limit = {
node_type: spread_pg_nodes_to_add.get(node_type, 0)
+ pg_demands_nodes_max_launch_limit.get(node_type, 0)
for node_type in self.node_types
}
# Step 4/5: add nodes for pending tasks, actors, and non-strict spread
# groups
unfulfilled, _ = get_bin_pack_residual(node_resources, resource_demands)
logger.debug("Resource demands: {}".format(resource_demands))
logger.debug("Unfulfilled demands: {}".format(unfulfilled))
nodes_to_add_based_on_demand, final_unfulfilled = get_nodes_for(
self.node_types,
node_type_counts,
self.head_node_type,
max_to_add,
unfulfilled,
utilization_scorer=utilization_scorer,
)
logger.debug("Final unfulfilled: {}".format(final_unfulfilled))
# Merge nodes to add based on demand and nodes to add based on
# min_workers constraint. We add them because nodes to add based on
# demand was calculated after the min_workers constraint was respected.
total_nodes_to_add = {}
for node_type in self.node_types:
nodes_to_add = (
adjusted_min_workers.get(node_type, 0)
+ spread_pg_nodes_to_add.get(node_type, 0)
+ nodes_to_add_based_on_demand.get(node_type, 0)
)
if nodes_to_add > 0:
total_nodes_to_add[node_type] = nodes_to_add
# Limit the number of concurrent launches
total_nodes_to_add = self._get_concurrent_resource_demand_to_launch(
total_nodes_to_add,
unused_resources_by_ip.keys(),
nodes,
launching_nodes,
adjusted_min_workers,
placement_groups_nodes_max_limit,
)
logger.debug("Node requests: {}".format(total_nodes_to_add))
return total_nodes_to_add, final_unfulfilled
def _update_node_resources_from_runtime(
self, nodes: List[NodeID], max_resources_by_ip: Dict[NodeIP, ResourceDict]
):
"""Update static node type resources with runtime resources
This will update the cached static node type resources with the runtime
resources. Because we can not know the exact autofilled memory or
object_store_memory from config file.
"""
need_update = len(self.node_types) != len(self.node_resource_updated)
if not need_update:
return
for node_id in nodes:
tags = self.provider.node_tags(node_id)
if TAG_RAY_USER_NODE_TYPE not in tags:
continue
node_type = tags[TAG_RAY_USER_NODE_TYPE]
if (
node_type in self.node_resource_updated
or node_type not in self.node_types
):
# continue if the node type has been updated or is not an known
# node type
continue
ip = self.provider.internal_ip(node_id)
runtime_resources = max_resources_by_ip.get(ip)
if runtime_resources:
runtime_resources = copy.deepcopy(runtime_resources)
resources = self.node_types[node_type].get("resources", {})
for key in ["CPU", "GPU", "memory"]:
if key in runtime_resources:
resources[key] = runtime_resources[key]
self.node_types[node_type]["resources"] = resources
node_kind = tags[TAG_RAY_NODE_KIND]
if node_kind == NODE_KIND_WORKER:
# Here, we do not record the resources have been updated
# if it is the head node kind. Because it need be updated
# by worker kind runtime resource. The most difference
# between head and worker is the memory resources. The head
# node needs to configure redis memory which is not needed
# for worker nodes.
self.node_resource_updated.add(node_type)
def _get_concurrent_resource_demand_to_launch(
self,
to_launch: Dict[NodeType, int],
connected_nodes: List[NodeIP],
non_terminated_nodes: List[NodeID],
pending_launches_nodes: Dict[NodeType, int],
adjusted_min_workers: Dict[NodeType, int],
placement_group_nodes: Dict[NodeType, int],
) -> Dict[NodeType, int]:
"""Updates the max concurrent resources to launch for each node type.
Given the current nodes that should be launched, the non terminated
nodes (running and pending) and the pending to be launched nodes. This
method calculates the maximum number of nodes to launch concurrently
for each node type as follows:
1) Calculates the running nodes.
2) Calculates the pending nodes and gets the launching nodes.
3) Limits the total number of pending + currently-launching +
to-be-launched nodes to:
max(
5,
self.upscaling_speed * max(running_nodes[node_type], 1)
).
Args:
to_launch: List of number of nodes to launch based on resource
demand for every node type.
connected_nodes: Running nodes (from LoadMetrics).
non_terminated_nodes: Non terminated nodes (pending/running).
pending_launches_nodes: Nodes that are in the launch queue.
adjusted_min_workers: Nodes to launch to satisfy
min_workers and request_resources(). This overrides the launch
limits since the user is hinting to immediately scale up to
this size.
placement_group_nodes: Nodes to launch for placement groups.
This overrides the launch concurrency limits.
Returns:
Dict[NodeType, int]: Maximum number of nodes to launch for each
node type.
"""
updated_nodes_to_launch = {}
running_nodes, pending_nodes = self._separate_running_and_pending_nodes(
non_terminated_nodes,
connected_nodes,
)
for node_type in to_launch:
# Enforce here max allowed pending nodes to be frac of total
# running nodes.
max_allowed_pending_nodes = max(
AUTOSCALER_UPSCALING_INITIAL_NUM_NODES,
int(self.upscaling_speed * max(running_nodes[node_type], 1)),
)
total_pending_nodes = (
pending_launches_nodes.get(node_type, 0) + pending_nodes[node_type]
)
upper_bound = max(
max_allowed_pending_nodes - total_pending_nodes,
# Allow more nodes if this is to respect min_workers or
# request_resources() or placement groups.
adjusted_min_workers.get(node_type, 0)
+ placement_group_nodes.get(node_type, 0),
)
if upper_bound > 0:
updated_nodes_to_launch[node_type] = min(
upper_bound, to_launch[node_type]
)
return updated_nodes_to_launch
def _separate_running_and_pending_nodes(
self,
non_terminated_nodes: List[NodeID],
connected_nodes: List[NodeIP],
) -> Tuple[Dict[NodeType, int], Dict[NodeType, int]]:
"""Splits connected and non terminated nodes to pending & running."""
running_nodes = collections.defaultdict(int)
pending_nodes = collections.defaultdict(int)
for node_id in non_terminated_nodes:
tags = self.provider.node_tags(node_id)
if TAG_RAY_USER_NODE_TYPE in tags:
node_type = tags[TAG_RAY_USER_NODE_TYPE]
node_ip = self.provider.internal_ip(node_id)
if node_ip in connected_nodes:
running_nodes[node_type] += 1
else:
pending_nodes[node_type] += 1
return running_nodes, pending_nodes
def calculate_node_resources(
self,
nodes: List[NodeID],
pending_nodes: Dict[NodeID, int],
unused_resources_by_ip: Dict[str, ResourceDict],
) -> Tuple[List[ResourceDict], Dict[NodeType, int]]:
"""Returns node resource list and node type counts.
Counts the running nodes, pending nodes.
Args:
nodes: Existing nodes.
pending_nodes: Pending nodes.
unused_resources_by_ip: Mapping from node IP to available resources.
Returns:
node_resources: a list of running + pending resources.
E.g., [{"CPU": 4}, {"GPU": 2}].
node_type_counts: running + pending workers per node type.
"""
node_resources = []
node_type_counts = collections.defaultdict(int)
def add_node(node_type, available_resources=None):
if node_type not in self.node_types:
# We should not get here, but if for some reason we do, log an
# error and skip the errant node_type.
logger.error(
f"Missing entry for node_type {node_type} in "
f"cluster config: {self.node_types} under entry "
"available_node_types. This node's resources will be "
"ignored. If you are using an unmanaged node, manually "
f"set the {TAG_RAY_NODE_KIND} tag to "
f'"{NODE_KIND_UNMANAGED}" in your cloud provider\'s '
"management console."
)
return None
# Careful not to include the same dict object multiple times.
available = copy.deepcopy(self.node_types[node_type]["resources"])
# If available_resources is None this might be because the node is
# no longer pending, but the raylet hasn't sent a heartbeat to gcs
# yet.
if available_resources is not None:
available = copy.deepcopy(available_resources)
node_resources.append(available)
node_type_counts[node_type] += 1
for node_id in nodes:
tags = self.provider.node_tags(node_id)
if TAG_RAY_USER_NODE_TYPE in tags:
node_type = tags[TAG_RAY_USER_NODE_TYPE]
ip = self.provider.internal_ip(node_id)
available_resources = unused_resources_by_ip.get(ip)
add_node(node_type, available_resources)
for node_type, count in pending_nodes.items():
for _ in range(count):
add_node(node_type)
return node_resources, node_type_counts
def reserve_and_allocate_spread(
self,
strict_spreads: List[List[ResourceDict]],
node_resources: List[ResourceDict],
node_type_counts: Dict[NodeType, int],
utilization_scorer: Callable[
[NodeResources, ResourceDemands], Optional[UtilizationScore]
],
):
"""For each strict spread, attempt to reserve as much space as possible
on the node, then allocate new nodes for the unfulfilled portion.
Args:
strict_spreads: A list of placement groups which must be spread out.
node_resources: Available node resources in the cluster.
node_type_counts: The amount of each type of node pending or in the
cluster.
utilization_scorer: A function that, given a node
type, its resources, and resource demands, returns what its
utilization would be.
Returns:
Nodes to add.
The updated node_resources after the method.
The updated node_type_counts.
"""
to_add = collections.defaultdict(int)
for bundles in strict_spreads:
# Try to pack as many bundles of this group as possible on existing
# nodes. The remaining will be allocated on new nodes.
unfulfilled, updated_node_resources = get_bin_pack_residual(
node_resources, bundles, strict_spread=True
)
max_to_add = self.max_workers + 1 - sum(node_type_counts.values())
# Allocate new nodes for the remaining bundles that don't fit.
to_launch, _ = get_nodes_for(
self.node_types,
node_type_counts,
self.head_node_type,
max_to_add,
unfulfilled,
utilization_scorer=utilization_scorer,
strict_spread=True,
)
new_node_resources = _node_type_counts_to_node_resources(
self.node_types, to_launch
)
# Update node resources to include newly launched nodes and their
# bundles.
unfulfilled, including_reserved = get_bin_pack_residual(
new_node_resources, unfulfilled, strict_spread=True
)
if unfulfilled:
logger.debug(
"Unfulfilled strict spread placement group: {}".format(bundles)
)
continue
_inplace_add(node_type_counts, to_launch)
_inplace_add(to_add, to_launch)
node_resources = updated_node_resources + including_reserved
return to_add, node_resources, node_type_counts
def debug_string(
self,
nodes: List[NodeID],
pending_nodes: Dict[NodeID, int],
unused_resources_by_ip: Dict[str, ResourceDict],
) -> str:
node_resources, node_type_counts = self.calculate_node_resources(
nodes, pending_nodes, unused_resources_by_ip
)
out = "Worker node types:"
for node_type, count in node_type_counts.items():
out += "\n - {}: {}".format(node_type, count)
if pending_nodes.get(node_type):
out += " ({} pending)".format(pending_nodes[node_type])
return out
def _node_type_counts_to_node_resources(
node_types: Dict[NodeType, NodeTypeConfigDict],
node_type_counts: Dict[NodeType, int],
) -> List[ResourceDict]:
"""Converts a node_type_counts dict into a list of node_resources."""
resources = []
for node_type, count in node_type_counts.items():
# Be careful, each entry in the list must be deep copied!
resources += [node_types[node_type]["resources"].copy() for _ in range(count)]
return resources
def _add_min_workers_nodes(
node_resources: List[ResourceDict],
node_type_counts: Dict[NodeType, int],
node_types: Dict[NodeType, NodeTypeConfigDict],
max_workers: int,
head_node_type: NodeType,
ensure_min_cluster_size: List[ResourceDict],
utilization_scorer: Callable[
[NodeResources, ResourceDemands, str], Optional[UtilizationScore]
],
) -> Tuple[List[ResourceDict], Dict[NodeType, int], Dict[NodeType, int]]:
"""Updates resource demands to respect the min_workers and
request_resources() constraints.
Args:
node_resources: Resources of existing nodes already launched/pending.
node_type_counts: Counts of existing nodes already launched/pending.
node_types: Node types config.
max_workers: global max_workers constaint.
head_node_type: The node type used for the head node.
ensure_min_cluster_size: resource demands from request_resources().
utilization_scorer: A function that, given a node
type, its resources, and resource demands, returns what its
utilization would be.
Returns:
node_resources: The updated node resources after adding min_workers
and request_resources() constraints per node type.
node_type_counts: The updated node counts after adding min_workers
and request_resources() constraints per node type.
total_nodes_to_add_dict: The nodes to add to respect min_workers and
request_resources() constraints.
"""
total_nodes_to_add_dict = {}
for node_type, config in node_types.items():
existing = node_type_counts.get(node_type, 0)
target = min(config.get("min_workers", 0), config.get("max_workers", 0))
if node_type == head_node_type:
# Add 1 to account for head node.
target = target + 1
if existing < target:
total_nodes_to_add_dict[node_type] = target - existing
node_type_counts[node_type] = target
node_resources.extend(
[
copy.deepcopy(node_types[node_type]["resources"])
for _ in range(total_nodes_to_add_dict[node_type])
]
)
if ensure_min_cluster_size:
max_to_add = max_workers + 1 - sum(node_type_counts.values())
max_node_resources = []
# Fit request_resources() on all the resources as if they are idle.
for node_type in node_type_counts:
max_node_resources.extend(
[
copy.deepcopy(node_types[node_type]["resources"])
for _ in range(node_type_counts[node_type])
]
)
# Get the unfulfilled to ensure min cluster size.
resource_requests_unfulfilled, _ = get_bin_pack_residual(
max_node_resources, ensure_min_cluster_size
)
# Get the nodes to meet the unfulfilled.
nodes_to_add_request_resources, _ = get_nodes_for(
node_types,
node_type_counts,
head_node_type,
max_to_add,
resource_requests_unfulfilled,
utilization_scorer=utilization_scorer,
)
# Update the resources, counts and total nodes to add.
for node_type in nodes_to_add_request_resources:
nodes_to_add = nodes_to_add_request_resources.get(node_type, 0)
if nodes_to_add > 0:
node_type_counts[node_type] = nodes_to_add + node_type_counts.get(
node_type, 0
)
node_resources.extend(
[
copy.deepcopy(node_types[node_type]["resources"])
for _ in range(nodes_to_add)
]
)
total_nodes_to_add_dict[
node_type
] = nodes_to_add + total_nodes_to_add_dict.get(node_type, 0)
return node_resources, node_type_counts, total_nodes_to_add_dict
def get_nodes_for(
node_types: Dict[NodeType, NodeTypeConfigDict],
existing_nodes: Dict[NodeType, int],
head_node_type: NodeType,
max_to_add: int,
resources: List[ResourceDict],
utilization_scorer: Callable[
[NodeResources, ResourceDemands, str], Optional[UtilizationScore]
],
strict_spread: bool = False,
) -> Tuple[Dict[NodeType, int], List[ResourceDict]]:
"""Determine nodes to add given resource demands and constraints.
Args:
node_types: node types config.
existing_nodes: counts of existing nodes already launched.
This sets constraints on the number of new nodes to add.
head_node_type: The node type used for the head node.
max_to_add: global constraint on nodes to add.
resources: resource demands to fulfill.
utilization_scorer: A function that, given a node
type, its resources, and resource demands, returns what its
utilization would be.
strict_spread: If true, each element in `resources` must be placed on a
different node.
Returns:
Dict of count to add for each node type, and residual of resources
that still cannot be fulfilled.
"""
nodes_to_add: Dict[NodeType, int] = collections.defaultdict(int)
while resources and sum(nodes_to_add.values()) < max_to_add:
utilization_scores = []
for node_type in node_types:
max_workers_of_node_type = node_types[node_type].get("max_workers", 0)
if head_node_type == node_type:
# Add 1 to account for head node.
max_workers_of_node_type = max_workers_of_node_type + 1
if (
existing_nodes.get(node_type, 0) + nodes_to_add.get(node_type, 0)
>= max_workers_of_node_type
):
continue
node_resources = node_types[node_type]["resources"]
if strict_spread:
# If handling strict spread, only one bundle can be placed on
# the node.
score = utilization_scorer(node_resources, [resources[0]], node_type)
else:
score = utilization_scorer(node_resources, resources, node_type)
if score is not None:
utilization_scores.append((score, node_type))
# Give up, no feasible node.
if not utilization_scores:
if not any(
is_placement_group_resource(resource)
for resources_dict in resources
for resource in resources_dict
):
logger.warning(
f"The autoscaler could not find a node type to satisfy the "
f"request: {resources}. Please specify a node type with the "
f"necessary resources."
)
break
utilization_scores = sorted(utilization_scores, reverse=True)
best_node_type = utilization_scores[0][1]
nodes_to_add[best_node_type] += 1
if strict_spread:
resources = resources[1:]
else:
allocated_resource = node_types[best_node_type]["resources"]
residual, _ = get_bin_pack_residual([allocated_resource], resources)
assert len(residual) < len(resources), (resources, residual)
resources = residual
return nodes_to_add, resources
def _resource_based_utilization_scorer(
node_resources: ResourceDict,
resources: List[ResourceDict],
*,
node_availability_summary: NodeAvailabilitySummary,
) -> Optional[Tuple[bool, int, float, float]]:
remaining = copy.deepcopy(node_resources)
fittable = []
resource_types = set()
for r in resources:
for k, v in r.items():
if v > 0:
resource_types.add(k)
if _fits(remaining, r):
fittable.append(r)
_inplace_subtract(remaining, r)
if not fittable:
return None
util_by_resources = []
num_matching_resource_types = 0
for k, v in node_resources.items():
# Don't divide by zero.
if v < 1:
# Could test v == 0 on the nose, but v < 1 feels safer.
# (Note that node resources are integers.)
continue
if k in resource_types:
num_matching_resource_types += 1
util = (v - remaining[k]) / v
util_by_resources.append(v * (util**3))
# Could happen if node_resources has only zero values.
if not util_by_resources:
return None
# Prefer not to launch a GPU node if there aren't any GPU requirements in the
# resource bundle.
gpu_ok = True
if AUTOSCALER_CONSERVE_GPU_NODES:
is_gpu_node = "GPU" in node_resources and node_resources["GPU"] > 0
any_gpu_task = any("GPU" in r for r in resources)
if is_gpu_node and not any_gpu_task:
gpu_ok = False
# Prioritize avoiding gpu nodes for non-gpu workloads first,
# then prioritize matching multiple resource types,
# then prioritize using all resources,
# then prioritize overall balance of multiple resources.
return (
gpu_ok,
num_matching_resource_types,
min(util_by_resources),
# util_by_resources should be non empty
float(sum(util_by_resources)) / len(util_by_resources),
)
def _default_utilization_scorer(
node_resources: ResourceDict,
resources: List[ResourceDict],
node_type: str,
*,
node_availability_summary: NodeAvailabilitySummary,
):
return _resource_based_utilization_scorer(
node_resources, resources, node_availability_summary=node_availability_summary
)
def get_bin_pack_residual(
node_resources: List[ResourceDict],
resource_demands: List[ResourceDict],
strict_spread: bool = False,
) -> Tuple[List[ResourceDict], List[ResourceDict]]:
"""Return a subset of resource_demands that cannot fit in the cluster.
TODO(ekl): this currently does not guarantee the resources will be packed
correctly by the Ray scheduler. This is only possible once the Ray backend
supports a placement groups API.
Args:
node_resources: List of resources per node.
resource_demands: List of resource bundles that need to be bin packed
onto the nodes.
strict_spread: If true, each element in resource_demands must be
placed on a different entry in `node_resources`.
Returns:
The residual list resources that do not fit.
The updated node_resources after the method. The order of the list
elements remains unchanged.
"""
unfulfilled = []
# A most naive bin packing algorithm.
nodes = copy.deepcopy(node_resources)
# List of nodes that cannot be used again due to strict spread.
used = set()
# We order the resource demands in the following way:
# More complex demands first.
# Break ties: heavier demands first.
# Break ties: lexicographically (to ensure stable ordering).
for demand in sorted(
resource_demands,
key=lambda demand: (
len(demand.values()),
sum(demand.values()),
sorted(demand.items()),
),
reverse=True,
):
found = False
node = None
for i in range(len(nodes)):
if i in used:
continue
node = nodes[i]
if _fits(node, demand):
found = True
# In the strict_spread case, we can't reuse nodes.
if strict_spread:
used.add(i)
break
if found and node:
_inplace_subtract(node, demand)
else:
unfulfilled.append(demand)
return unfulfilled, nodes
def _fits(node: ResourceDict, resources: ResourceDict) -> bool:
for k, v in resources.items():
# TODO(jjyao): Change ResourceDict to a class so we can
# hide the implicit resource handling.
if v > node.get(
k, 1.0 if k.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX) else 0.0
):
return False
return True
def _inplace_subtract(node: ResourceDict, resources: ResourceDict) -> None:
for k, v in resources.items():
if v == 0:
# This is an edge case since some reasonable programs/computers can
# do `ray.autoscaler.sdk.request_resources({"GPU": 0}"})`.
continue
if k not in node:
assert k.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX), (k, node)
node[k] = 1
assert k in node, (k, node)
node[k] -= v
assert node[k] >= 0.0, (node, k, v)
def _inplace_add(a: collections.defaultdict, b: Dict) -> None:
"""Generically adds values in `b` to `a`.
a[k] should be defined for all k in b.keys()"""
for k, v in b.items():
a[k] += v
def placement_groups_to_resource_demands(
pending_placement_groups: List[PlacementGroupTableData],
) -> Tuple[List[ResourceDict], List[List[ResourceDict]]]:
"""Preprocess placement group requests into regular resource demand vectors
when possible. The policy is:
* STRICT_PACK - Convert to a single bundle.
* PACK - Flatten into a resource demand vector.
* STRICT_SPREAD - Cannot be converted.
* SPREAD - Flatten into a resource demand vector.
Args:
pending_placement_groups: List of PlacementGroupLoad's.
Returns:
The placement groups which were converted to a resource demand vector.
The placement groups which should be strictly spread.
"""
resource_demand_vector = []
unconverted = []
for placement_group in pending_placement_groups:
# Skip **placed** bundle (which has node id associated with it).
shapes = []
for bundle in placement_group.bundles:
if bundle.node_id != b"":
continue
shapes.append(dict(bundle.unit_resources))
if (
placement_group.strategy == PlacementStrategy.PACK
or placement_group.strategy == PlacementStrategy.SPREAD
):
resource_demand_vector.extend(shapes)
elif placement_group.strategy == PlacementStrategy.STRICT_PACK:
combined = collections.defaultdict(float)
for shape in shapes:
for label, quantity in shape.items():
combined[label] += quantity
resource_demand_vector.append(combined)
elif placement_group.strategy == PlacementStrategy.STRICT_SPREAD:
unconverted.append(shapes)
else:
logger.error(
f"Unknown placement group request type: {placement_group}. "
f"Please file a bug report "
f"https://github.com/ray-project/ray/issues/new."
)
return resource_demand_vector, unconverted