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

622 lines
23 KiB
Python

import re
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
from ray.core.generated.autoscaler_pb2 import NodeState, NodeStatus
from ray.core.generated.instance_manager_pb2 import Instance
# TODO(rickyx): once we have graceful shutdown, we could populate
# the failure detail with the actual termination message. As of now,
# we will use a more generic message to include cases such as:
# (idle termination, node death, crash, preemption, etc)
NODE_DEATH_CAUSE_RAYLET_DIED = "NodeTerminated"
# e.g., cpu_4_ondemand.
NodeType = str
@dataclass
class ResourceUsage:
# Resource name.
resource_name: str = ""
# Total resource.
total: float = 0.0
# Resource used.
used: float = 0.0
@dataclass
class NodeUsage:
# The node resource usage.
usage: List[ResourceUsage]
# How long the node has been idle.
idle_time_ms: int
@dataclass
class NodeInfo:
# The instance type name, e.g. p3.2xlarge
instance_type_name: str
# ray node type name.
ray_node_type_name: str
# Cloud instance id.
instance_id: str
# Ip address of the node when alive.
ip_address: str
# The status of the node. Optional for pending nodes.
node_status: Optional[str] = None
# ray node id in hex. None if still pending.
node_id: Optional[str] = None
# Resource usage breakdown if node is running.
resource_usage: Optional[NodeUsage] = None
# Failure detail if the node failed.
failure_detail: Optional[str] = None
# Descriptive details.
details: Optional[str] = None
# Activity on the node.
node_activity: Optional[List[str]] = None
# Ray node labels.
labels: Optional[Dict[str, str]] = None
def total_resources(self) -> Dict[str, float]:
if self.resource_usage is None:
return {}
return {r.resource_name: r.total for r in self.resource_usage.usage}
def available_resources(self) -> Dict[str, float]:
if self.resource_usage is None:
return {}
return {r.resource_name: r.total - r.used for r in self.resource_usage.usage}
def used_resources(self) -> Dict[str, float]:
if self.resource_usage is None:
return {}
return {r.resource_name: r.used for r in self.resource_usage.usage}
@dataclass
class LaunchRequest:
class Status(Enum):
FAILED = "FAILED"
PENDING = "PENDING"
# The instance type name, e.g. p3.2xlarge
instance_type_name: str
# ray node type name.
ray_node_type_name: str
# count.
count: int
# State: (e.g. PENDING, FAILED)
state: Status
# When the launch request was made in unix timestamp in secs.
request_ts_s: int
# When the launch request failed unix timestamp in secs if failed.
failed_ts_s: Optional[int] = None
# Request details, e.g. error reason if the launch request failed.
details: Optional[str] = None
@dataclass
class ResourceRequestByCount:
# Bundles in the demand.
bundle: Dict[str, float]
# Number of bundles with the same shape.
count: int
def __str__(self) -> str:
return f"[{self.count} {self.bundle}]"
@dataclass
class ResourceDemand:
# The bundles in the demand with shape and count info.
bundles_by_count: List[ResourceRequestByCount]
@dataclass
class PlacementGroupResourceDemand(ResourceDemand):
# Details string (parsed into below information)
details: str
# Placement group's id.
pg_id: Optional[str] = None
# Strategy, e.g. STRICT_SPREAD
strategy: Optional[str] = None
# Placement group's state, e.g. PENDING
state: Optional[str] = None
def __post_init__(self):
if not self.details:
return
# Details in the format of <pg_id>:<strategy>|<state>, parse
# it into the above fields.
pattern = r"^.*:.*\|.*$"
match = re.match(pattern, self.details)
if not match:
return
pg_id, details = self.details.split(":")
strategy, state = details.split("|")
self.pg_id = pg_id
self.strategy = strategy
self.state = state
@dataclass
class RayTaskActorDemand(ResourceDemand):
pass
@dataclass
class ClusterConstraintDemand(ResourceDemand):
pass
@dataclass
class ResourceDemandSummary:
# Placement group demand.
placement_group_demand: List[PlacementGroupResourceDemand] = field(
default_factory=list
)
# Ray task actor demand.
ray_task_actor_demand: List[RayTaskActorDemand] = field(default_factory=list)
# Cluster constraint demand.
cluster_constraint_demand: List[ClusterConstraintDemand] = field(
default_factory=list
)
@dataclass
class Stats:
# How long it took to get the GCS request.
gcs_request_time_s: float = 0.0
# How long it took to get all live instances from node provider.
none_terminated_node_request_time_s: Optional[float] = None
# How long for autoscaler to process the scaling decision.
autoscaler_iteration_time_s: Optional[float] = None
# The last seen autoscaler state version from Ray.
autoscaler_version: Optional[str] = None
# The last seen cluster state resource version.
cluster_resource_state_version: Optional[str] = None
# Request made time unix timestamp: when the data was pulled from GCS.
request_ts_s: Optional[int] = None
@dataclass
class ClusterStatus:
# Healthy nodes information (non-idle)
active_nodes: List[NodeInfo] = field(default_factory=list)
# Idle node information
idle_nodes: List[NodeInfo] = field(default_factory=list)
# Pending launches.
pending_launches: List[LaunchRequest] = field(default_factory=list)
# Failed launches.
failed_launches: List[LaunchRequest] = field(default_factory=list)
# Pending nodes.
pending_nodes: List[NodeInfo] = field(default_factory=list)
# Failures
failed_nodes: List[NodeInfo] = field(default_factory=list)
# Resource usage summary for entire cluster.
cluster_resource_usage: List[ResourceUsage] = field(default_factory=list)
# Demand summary.
resource_demands: ResourceDemandSummary = field(
default_factory=ResourceDemandSummary
)
# Query metics
stats: Stats = field(default_factory=Stats)
def total_resources(self) -> Dict[str, float]:
return {r.resource_name: r.total for r in self.cluster_resource_usage}
def available_resources(self) -> Dict[str, float]:
return {r.resource_name: r.total - r.used for r in self.cluster_resource_usage}
# TODO(rickyx): we don't show infeasible requests as of now.
# (They will just be pending forever as part of the demands)
# We should show them properly in the future.
IPPRSpecsSchema = {
# JSON schema for IPPR (In-Place Pod Resize) specs provided via the
# Kubernetes annotation `ray.io/ippr` on a RayCluster CR.
#
# Structure:
# {
# "groups": {
# "<groupName>": {
# "max-cpu": string|number, # K8s quantity (e.g. "2", "1500m")
# "max-memory": string|integer, # K8s quantity (e.g. "8Gi", 2147483648)
# "resize-timeout": integer # Seconds to wait for a pod resize to
# # complete before considering it timed out
# },
# ...
# }
# }
#
# Notes:
# - The set of valid <groupName> keys corresponds to the RayCluster
# `workerGroupSpecs[].groupName` plus the implicit "headgroup".
# - The minimal CPU/memory values (min_*) are derived from the pod template
# requests/limits and are not part of this schema.
"type": "object",
"properties": {
"groups": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"max-cpu": {"type": ["string", "number"]},
"max-memory": {"type": ["string", "integer"]},
"resize-timeout": {"type": "integer"},
},
"required": ["max-cpu", "max-memory", "resize-timeout"],
},
}
},
}
@dataclass
class IPPRGroupSpec:
"""Per-group IPPR limits and baseline resources.
This mirrors a single Ray group (worker group or head group). The minimal
resources are derived from the pod template's container resources (either
requests or limits if present), and the maximal resources and timeout are
provided by the IPPR spec annotation validated by ``IPPRSpecsSchema``.
Attributes:
min_cpu: Baseline CPU in cores derived from the pod template (float, e.g., 1.5).
max_cpu: Maximum CPU in cores allowed for in-place resize for this group.
min_memory: Baseline memory in bytes derived from the pod template.
max_memory: Maximum memory in bytes allowed for in-place resize for this group.
resize_timeout: Timeout in seconds for a single resize operation before
it is considered timed out.
"""
min_cpu: float
max_cpu: float
min_memory: int
max_memory: int
resize_timeout: int
@dataclass
class IPPRSpecs:
"""Typed, validated IPPR specs across Ray groups.
Attributes:
groups: Mapping from Ray group name (e.g., worker group ``groupName`` or
``"headgroup"``) to its ``IPPRGroupSpec``.
"""
groups: Dict[str, IPPRGroupSpec]
@dataclass
class IPPRStatus:
"""Represents the current and target resources for a pod under IPPR.
This structure is the working state used by the autoscaler to decide if and
when to apply in-place pod resizes and when to synchronize resource changes
with the Raylet.
Attributes:
cloud_instance_id: Cloud instance identifier for the pod (K8s pod name).
spec: The group-level limits and baselines for this pod.
current_cpu: Current CPU allocation in cores from the pod status.
current_memory: Current memory allocation in bytes from the pod status.
desired_cpu: Target CPU allocation in cores.
desired_memory: Target memory allocation in bytes.
resizing_at: Unix timestamp (seconds) when a resize request was issued to
Kubernetes, or None if not pending/needed.
k8s_resize_status: Lower-cased status from pod conditions for IPPR, e.g.
"inprogress", "deferred", "infeasible", "error"; None indicates no
active resize and is treated as finished.
k8s_resize_message: Message from the pod condition describing the resize
state or failure, if any.
suggested_max_cpu: Current-iteration suggested CPU cap (cores), computed from
node remaining capacity plus existing gap when resize is deferred/infeasible.
suggested_max_memory: Current-iteration suggested memory cap (bytes), computed
from node remaining capacity plus existing gap when resize is deferred/infeasible.
last_failed_at: Unix timestamp (seconds) when this pod most recently hit a
terminal IPPR failure. Once set, the autoscaler stops sending further
IPPR requests for this pod.
last_failed_reason: Human-readable reason for the terminal IPPR failure.
raylet_id: Raylet node id (hex) running in this pod, used to sync Ray's
internal resource view when K8s successfully changed pod resources.
"""
cloud_instance_id: str
spec: IPPRGroupSpec
current_cpu: float
current_memory: int
desired_cpu: float
desired_memory: int
resizing_at: Optional[int] = None
k8s_resize_status: Optional[str] = None
k8s_resize_message: Optional[str] = None
suggested_max_cpu: Optional[float] = None
suggested_max_memory: Optional[int] = None
last_failed_at: Optional[int] = None
last_failed_reason: Optional[str] = None
raylet_id: Optional[str] = None
def queue_resize_request(
self,
desired_cpu: Optional[float] = None,
desired_memory: Optional[int] = None,
) -> bool:
"""Queue the new desired resources and reset resize tracking state.
Queues the new desired CPU/memory if provided, associates the Raylet id,
and marks the resize state as "new" so the scheduler can identify the IPPR
action before the next iteration.
Args:
desired_cpu: Optional new desired CPU in cores.
desired_memory: Optional new desired memory in bytes.
Returns:
bool: True if the resize request is queued.
"""
updated = False
if desired_cpu is not None and desired_cpu != self.desired_cpu:
self.desired_cpu = desired_cpu
updated = True
if desired_memory is not None and desired_memory != self.desired_memory:
self.desired_memory = desired_memory
updated = True
if updated:
self.resizing_at = None
self.k8s_resize_status = "new"
self.k8s_resize_message = None
return updated
def has_resize_request_to_send(self) -> bool:
"""Whether this pod should be sent an IPPR request now.
Returns True if there is a Raylet id and the status is marked as "new".
"""
return self.raylet_id is not None and self.k8s_resize_status == "new"
def is_in_progress(self) -> bool:
"""Whether a resize is on going or about to be issued.
True if a resize was already issued to K8s (``resizing_at`` set), or if
the status is newly queued for resize (``has_resize_request_to_send``).
"""
return self.resizing_at is not None or self.has_resize_request_to_send()
def is_k8s_resize_finished(self) -> bool:
"""Whether the Kubernetes-side resize is considered finished.
We treat ``k8s_resize_status is None`` as finished. After K8s completes a
resize, the provider clears ``k8s_resize_status`` as None.
"""
return self.k8s_resize_status is None
def is_raylet_synced(self) -> bool:
"""Whether the Raylet's internal resources have been updated.
After the Raylet has been synchronized,
the provider clears ``resizing_at``.
"""
return self.resizing_at is None
def need_sync_with_raylet(self) -> bool:
"""Whether the Raylet's internal resources need to be updated.
Returns True when all of the following hold:
- We know the ``raylet_id`` for this pod.
- A resize was previously issued to Kubernetes (``resizing_at`` set).
- Kubernetes has finished applying the resize (``k8s_resize_status`` is None).
- The pod's current resources equal the desired resources.
In this case, the provider will call GCS's resize_raylet_resource_instances to update the raylet's local
resource instances and then clear ``resizing_at`` on the pod annotation.
"""
return (
self.raylet_id is not None
and self.resizing_at is not None
and self.k8s_resize_status is None
and self.desired_cpu == self.current_cpu
and self.desired_memory == self.current_memory
)
def max_cpu(self) -> float:
"""Effective maximum CPU cores allowed for this pod.
Preference order:
1) ``suggested_max_cpu`` (discovered limit)
2) ``spec.max_cpu`` (static limit)
"""
if self.suggested_max_cpu is not None:
return self.suggested_max_cpu
return self.spec.max_cpu
def max_memory(self) -> int:
"""Effective maximum memory bytes allowed for this pod.
Preference order:
1) ``suggested_max_memory`` (discovered limit)
2) ``spec.max_memory`` (static limit)
"""
if self.suggested_max_memory is not None:
return self.suggested_max_memory
return self.spec.max_memory
def can_resize_up(self) -> bool:
"""Whether the pod can still be scaled up within allowed limits.
Only returns True when no resize is in progress and the current
CPU/memory are below the effective max limits. Pods that have already
hit a terminal IPPR failure are permanently excluded from future IPPR.
"""
return (
self.last_failed_at is None
and self.is_k8s_resize_finished()
and self.is_raylet_synced()
and (
self.current_cpu < self.max_cpu()
or self.current_memory < self.max_memory()
)
)
def is_timeout(self) -> bool:
"""Whether an in-flight resize has exceeded the group's timeout.
Returns True when a resize was issued and now current time exceeds
``resizing_at + spec.resize_timeout``.
"""
return (
self.resizing_at is not None
and not self.need_sync_with_raylet()
and (self.resizing_at + self.spec.resize_timeout) < time.time()
)
def is_errored(self) -> bool:
"""Whether the last resize attempt reported an error from Kubernetes."""
return self.k8s_resize_status == "error"
def record_failure(self, reason: str, failed_at: Optional[int] = None) -> None:
"""Record the IPPR failure."""
self.last_failed_at = int(time.time()) if failed_at is None else failed_at
self.last_failed_reason = reason
@dataclass
class AutoscalerInstance:
"""
AutoscalerInstance represents an instance that's managed by the autoscaler.
This includes two states:
1. the instance manager state: information of the underlying cloud instance.
2. the ray node state, e.g. resources, ray node status.
The two states are linked by the cloud instance id, which should be set
when the ray node is started.
"""
# The cloud instance id. It could be None if the instance hasn't been assigned
# a cloud instance id, e.g. the instance is still in QUEUED or REQUESTED status.
cloud_instance_id: Optional[str] = None
# The ray node state status. It could be None when no ray node is running
# or has run on the cloud instance: for example, ray is still being installed
# or the instance manager hasn't had a cloud instance assigned (e.g. QUEUED,
# REQUESTED).
ray_node: Optional[NodeState] = None
# The instance manager instance state. It would be None when the ray_node is not
# None.
# It could be None iff:
# 1. There's a ray node, but the instance manager hasn't discovered the
# cloud instance that's running this ray process yet. This could happen since
# the instance manager only discovers instances periodically.
#
# 2. There was a ray node running on the cloud instance, which was already stopped
# and removed from the instance manager state. But the ray state is still lagging
# behind.
#
# 3. There is a ray node that's unmanaged by the instance manager.
#
im_instance: Optional[Instance] = None
# | cloud_instance_id | ray_node | im_instance |
# |-------------------|----------|-------------|
# | None | None | None | Not possible.
# | None | None | not None | OK. An instance hasn't had ray running on it yet. # noqa E501
# | None | Not None | None | OK. Possible if the ray node is not started by autoscaler. # noqa E501
# | None | Not None | not None | Not possible - no way to link im instance with ray node. # noqa E501
# | not None | None | None | Not possible since cloud instance id is either part of im state or ray node. # noqa E501
# | not None | None | not None | OK. e.g. An instance that's not running ray yet. # noqa E501
# | not None | Not None | None | OK. See scenario 1, 2, 3 above.
# | not None | Not None | not None | OK. An instance that's running ray.
def validate(self) -> Tuple[bool, str]:
"""Validate the autoscaler instance state.
Returns:
A tuple of (valid, error_msg) where:
- valid is whether the state is valid
- error_msg is the error message for the validation results.
"""
state_combinations = {
# (cloud_instance_id is None, ray_node is None, im_instance is None): (valid, error_msg) # noqa E501
(True, True, True): (False, "Not possible"),
(True, True, False): (True, ""),
(True, False, True): (
True,
"There's a ray node w/o cloud instance id, must be started not "
"by autoscaler",
),
(True, False, False): (
False,
"Not possible - no way to link im instance with ray node",
),
(False, True, True): (
False,
"Not possible since cloud instance id is either part of "
"im state or ray node",
),
(False, True, False): (True, ""),
(False, False, True): (True, ""),
(False, False, False): (True, ""),
}
valid, error_msg = state_combinations[
(
self.cloud_instance_id is None,
self.ray_node is None,
self.im_instance is None,
)
]
if not valid:
return valid, error_msg
if self.im_instance is not None and self.ray_node is None:
# We don't see a ray node, but tracking an im instance.
if self.cloud_instance_id is None:
if InstanceUtil.is_cloud_instance_allocated(self.im_instance.status):
return (
False,
"instance should be in a status where cloud instance "
"is not allocated.",
)
else:
if not InstanceUtil.is_cloud_instance_allocated(
self.im_instance.status
):
return (
False,
"instance should be in a status where cloud instance is "
"allocated.",
)
if self.ray_node is not None:
if self.cloud_instance_id != self.ray_node.instance_id:
return False, "cloud instance id doesn't match."
if self.im_instance is not None and self.cloud_instance_id is not None:
if self.cloud_instance_id != self.im_instance.cloud_instance_id:
return False, "cloud instance id doesn't match."
return True, ""
def is_ray_running(self) -> bool:
"""Whether the ray node is running."""
return self.ray_node is not None and self.ray_node.status in [
NodeStatus.RUNNING,
NodeStatus.IDLE,
]
def is_ray_stop(self) -> bool:
"""Whether the ray node is stopped."""
return self.ray_node is None or self.ray_node.status in [
NodeStatus.DEAD,
]