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

1395 lines
56 KiB
Python

import copy
import logging
import uuid
import warnings
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from functools import total_ordering
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import ray
from ray._raylet import node_labels_match_selector
from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache
from ray.serve._private.common import (
GANG_PG_NAME_PREFIX,
CreatePlacementGroupRequest,
DeploymentID,
GangPlacementGroupRequest,
GangReservationResult,
ReplicaID,
)
from ray.serve._private.config import ReplicaConfig
from ray.serve._private.constants import (
RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES,
RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY,
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY,
SERVE_LOGGER_NAME,
)
from ray.util.placement_group import PlacementGroup
from ray.util.scheduling_strategies import (
LabelMatchExpressionsT,
NodeAffinitySchedulingStrategy,
NodeLabelSchedulingStrategy,
PlacementGroupSchedulingStrategy,
)
logger = logging.getLogger(SERVE_LOGGER_NAME)
class SpreadDeploymentSchedulingPolicy:
"""A scheduling policy that spreads replicas with best effort."""
pass
@total_ordering
class Resources(dict):
"""Base for per-node availability vs replica demand resource maps.
Do not instantiate directly; use ``AvailableNodeResources`` or
``RequestedResources``.
"""
# Custom resource priority from environment variable
CUSTOM_PRIORITY: List[str] = RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES
EPSILON = 1e-9
def __new__(cls, *args, **kwargs):
if cls is Resources:
raise TypeError(
"Resources cannot be instantiated directly; use "
"AvailableNodeResources or RequestedResources."
)
return super().__new__(cls)
def __eq__(self, other):
keys = set(self.keys()) | set(other.keys())
return all([self.get(k) == other.get(k) for k in keys])
def __add__(self, other):
keys = set(self.keys()) | set(other.keys())
kwargs = dict()
for key in keys:
if key.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX):
kwargs[key] = min(1.0, self.get(key) + other.get(key))
else:
kwargs[key] = self.get(key) + other.get(key)
return type(self)(kwargs)
def __sub__(self, other):
keys = set(self.keys()) | set(other.keys())
kwargs = {key: self.get(key) - other.get(key) for key in keys}
return type(self)(kwargs)
def can_fit(self, other):
keys = set(self.keys()) | set(other.keys())
# We add a small epsilon to avoid floating point precision issues.
return all(self.get(k) + self.EPSILON >= other.get(k) for k in keys)
def __lt__(self, other):
"""Determines priority when sorting a list of SoftResources.
1. Custom resources defined in RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES (sorted by priority)
2. GPU
3. CPU
4. memory
5. Other custom resources
This means a resource with a larger number of high-priority resources is always
sorted higher than one with fewer, regardless of other types.
"""
keys = set(self.keys()) | set(other.keys())
custom_keys = keys - {"GPU", "CPU", "memory"}
for key in self.CUSTOM_PRIORITY:
if self.get(key) < other.get(key):
return True
elif self.get(key) > other.get(key):
return False
if self.get("GPU") < other.get("GPU"):
return True
elif self.get("GPU") > other.get("GPU"):
return False
if self.get("CPU") < other.get("CPU"):
return True
elif self.get("CPU") > other.get("CPU"):
return False
if self.get("memory") < other.get("memory"):
return True
elif self.get("memory") > other.get("memory"):
return False
for key in custom_keys - set(self.CUSTOM_PRIORITY):
if self.get(key) < other.get(key):
return True
elif self.get(key) > other.get(key):
return False
return False
def _format_resources_for_scheduling_log(resources: Resources) -> str:
"""Compact resource summary for pack scheduling logs."""
priority_keys = list(Resources.CUSTOM_PRIORITY) + ["GPU", "CPU", "memory"]
seen = set()
parts = []
for key in priority_keys:
if key in seen:
continue
seen.add(key)
val = resources.get(key)
if val:
parts.append(f"{key}={val:g}" if isinstance(val, float) else f"{key}={val}")
for key in sorted(set(resources.keys()) - seen):
val = resources.get(key)
if val:
parts.append(f"{key}={val:g}" if isinstance(val, float) else f"{key}={val}")
return ", ".join(parts) if parts else "none"
class AvailableNodeResources(Resources):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get(self, key: str):
val = super().get(key)
if val is not None:
return val
# Implicit resources by default have 1 total
# NOTE(zcin): Implicit resources are automatically and
# artificially injected into each node and are used to limit how
# many replicas of the same deployment can run on a single node.
# This is used to enforce `max_replicas_per_node`.
if key.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX):
return 1
# Otherwise by default there is 0 of this resource
return 0
class RequestedResources(Resources):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get(self, key: str):
# We DON'T inject implicit resources for required resources.
val = super().get(key)
if val is not None:
return val
return 0
class ReplicaSchedulingRequestStatus(str, Enum):
"""The status of a replica scheduling request."""
IN_PROGRESS = "IN_PROGRESS"
SUCCEEDED = "SUCCEEDED"
ACTOR_CREATION_FAILED = "ACTOR_CREATION_FAILED"
PLACEMENT_GROUP_CREATION_FAILED = "PLACEMENT_GROUP_CREATION_FAILED"
@dataclass
class ReplicaSchedulingRequest:
"""Request to schedule a single replica.
The scheduler is responsible for scheduling
based on the deployment scheduling policy.
"""
replica_id: ReplicaID
actor_def: ray.actor.ActorClass
actor_resources: Dict
actor_options: Dict
actor_init_args: Tuple
on_scheduled: Callable
status: ReplicaSchedulingRequestStatus = ReplicaSchedulingRequestStatus.IN_PROGRESS
# Placement group bundles and strategy *for this replica*.
# These are optional: by default replicas do not have a placement group.
placement_group_bundles: Optional[List[Dict[str, float]]] = None
placement_group_strategy: Optional[str] = None
placement_group_bundle_label_selector: Optional[List[Dict[str, str]]] = None
placement_group_fallback_strategy: Optional[List[Dict[str, Any]]] = None
max_replicas_per_node: Optional[int] = None
# Gang scheduling fields -- if set, replica should be scheduled on
# the reserved gang placement group at the specified bundle index.
gang_placement_group: Optional[PlacementGroup] = None
# Bundle index inside gang_placement_group where this replica actor is scheduled.
# Example: If each replica uses 2 bundles, ranks 0 and 1 use indices 0 and 2 respectively.
gang_pg_index: Optional[int] = None
@property
def requested_resources(self) -> RequestedResources:
"""The resources required to schedule this replica on a node.
STRICT_PACK placement group: sum of all bundles.
Other placement groups: bundle 0.
Otherwise: actor resources.
"""
if (
self.placement_group_bundles is not None
and self.placement_group_strategy == "STRICT_PACK"
):
return sum(
[RequestedResources(bundle) for bundle in self.placement_group_bundles],
RequestedResources(),
)
elif self.placement_group_bundles is not None:
return RequestedResources(self.placement_group_bundles[0])
else:
required = RequestedResources(self.actor_resources)
# Using implicit resource (resources that every node
# implicitly has and total is 1)
# to limit the number of replicas on a single node.
if (
self.max_replicas_per_node is not None
and self.max_replicas_per_node > 0
):
deployment_id = self.replica_id.deployment_id
implicit_resource = (
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}"
f"{deployment_id.app_name}:{deployment_id.name}"
)
required[implicit_resource] = 1.0 / self.max_replicas_per_node
return required
@dataclass
class DeploymentDownscaleRequest:
"""Request to stop a certain number of replicas.
The scheduler is responsible for choosing the replicas to stop.
"""
deployment_id: DeploymentID
num_to_stop: int
# The scheduler uses these to select complete gangs to stop.
gang_id_by_replica: Optional[Dict[ReplicaID, str]] = None
replicas_by_gang_id: Optional[Dict[str, Set[ReplicaID]]] = None
gang_size: Optional[int] = None
@dataclass
class DeploymentSchedulingInfo:
deployment_id: DeploymentID
scheduling_policy: Any
actor_resources: Optional[RequestedResources] = None
label_selector: Optional[Dict[str, str]] = None
placement_group_bundles: Optional[List[RequestedResources]] = None
bundle_label_selector: Optional[List[Dict[str, str]]] = None
fallback_strategy: Optional[List[Dict[str, Any]]] = None
placement_group_strategy: Optional[str] = None
max_replicas_per_node: Optional[int] = None
@property
def required_resources(self) -> RequestedResources:
"""The resources required to schedule a replica of this deployment on a node.
STRICT_PACK placement group: sum of all bundles.
Other placement groups: bundle 0.
Otherwise: actor resources.
"""
if (
self.placement_group_bundles is not None
and self.placement_group_strategy == "STRICT_PACK"
):
return sum(self.placement_group_bundles, RequestedResources())
elif self.placement_group_bundles is not None:
return RequestedResources(self.placement_group_bundles[0])
else:
if self.actor_resources is None:
required = RequestedResources()
else:
required = RequestedResources(self.actor_resources)
# Using implicit resource (resources that every node
# implicitly has and total is 1)
# to limit the number of replicas on a single node.
if (
self.max_replicas_per_node is not None
and self.max_replicas_per_node > 0
):
implicit_resource = (
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}"
f"{self.deployment_id.app_name}:{self.deployment_id.name}"
)
required[implicit_resource] = 1.0 / self.max_replicas_per_node
return required
def is_non_strict_pack_pg(self) -> bool:
return (
self.placement_group_bundles is not None
and self.placement_group_strategy != "STRICT_PACK"
)
@dataclass
class LaunchingReplicaInfo:
"""Describes a replica for which a schedule request has been sent to
core but has not been scheduled (placed on a node) yet.
Args:
target_node_id: The exact node that's been requested for this
replica. This is best effort and may not be fulfilled.
target_labels: The node labels that have been requested for this
replica. This is best effort and may not be fulfilled.
"""
target_node_id: Optional[str] = None
target_labels: Optional[Dict[str, Any]] = None
def _flatten(
deployment_to_replicas: Dict[DeploymentID, Dict[ReplicaID, Any]],
) -> Dict[ReplicaID, Any]:
"""Flattens a dict of {deployment_id: {replica_id: val}} to {replica_id: val}."""
return {
replica_id: val
for replicas in deployment_to_replicas.values()
for replica_id, val in replicas.items()
}
class DeploymentScheduler(ABC):
"""A centralized scheduler for all Serve deployments.
It makes a batch of scheduling decisions in each update cycle.
"""
def __init__(
self,
cluster_node_info_cache: ClusterNodeInfoCache,
head_node_id: str,
create_placement_group_fn: Callable,
):
# {deployment_id: scheduling_policy}
self._deployments: Dict[DeploymentID, DeploymentSchedulingInfo] = {}
# Replicas that are waiting to be scheduled.
# {deployment_id: {replica_id: deployment_upscale_request}}
self._pending_replicas: Dict[
DeploymentID, Dict[str, ReplicaSchedulingRequest]
] = defaultdict(dict)
# Replicas that are being scheduled.
# The underlying actors have been submitted.
# {deployment_id: {replica_id: target_node_id}}
self._launching_replicas: Dict[
DeploymentID, Dict[str, LaunchingReplicaInfo]
] = defaultdict(dict)
# Replicas that are recovering.
# We don't know where those replicas are running.
# {deployment_id: {replica_id}}
self._recovering_replicas = defaultdict(set)
# Replicas that are running.
# We know where those replicas are running.
# {deployment_id: {replica_id: running_node_id}}
self._running_replicas = defaultdict(dict)
# Dedupes pack scheduling order logs across control loops.
self._last_pack_schedule_order_log_key: Optional[tuple] = None
# Dedupes repeated pack placement failure logs for stuck replicas.
self._logged_pack_placement_failures: Set[ReplicaID] = set()
self._cluster_node_info_cache = cluster_node_info_cache
self._head_node_id = head_node_id
self._create_placement_group_fn = create_placement_group_fn
def on_deployment_created(
self,
deployment_id: DeploymentID,
scheduling_policy: SpreadDeploymentSchedulingPolicy,
) -> None:
"""Called whenever a new deployment is created."""
assert deployment_id not in self._pending_replicas
assert deployment_id not in self._launching_replicas
assert deployment_id not in self._recovering_replicas
assert deployment_id not in self._running_replicas
self._deployments[deployment_id] = DeploymentSchedulingInfo(
deployment_id=deployment_id, scheduling_policy=scheduling_policy
)
def on_deployment_deployed(
self,
deployment_id: DeploymentID,
replica_config: ReplicaConfig,
) -> None:
assert deployment_id in self._deployments
info = self._deployments[deployment_id]
info.actor_resources = RequestedResources(replica_config.resource_dict)
info.label_selector = replica_config.ray_actor_options.get("label_selector")
info.bundle_label_selector = (
replica_config.placement_group_bundle_label_selector
)
info.fallback_strategy = replica_config.ray_actor_options.get(
"fallback_strategy"
)
info.max_replicas_per_node = replica_config.max_replicas_per_node
if replica_config.placement_group_bundles:
info.placement_group_bundles = [
RequestedResources(bundle)
for bundle in replica_config.placement_group_bundles
]
if replica_config.placement_group_strategy:
info.placement_group_strategy = replica_config.placement_group_strategy
def on_deployment_deleted(self, deployment_id: DeploymentID) -> None:
"""Called whenever a deployment is deleted."""
assert not self._pending_replicas[deployment_id]
self._pending_replicas.pop(deployment_id, None)
assert not self._launching_replicas[deployment_id]
self._launching_replicas.pop(deployment_id, None)
assert not self._recovering_replicas[deployment_id]
self._recovering_replicas.pop(deployment_id, None)
assert not self._running_replicas[deployment_id]
self._running_replicas.pop(deployment_id, None)
del self._deployments[deployment_id]
def on_replica_stopping(self, replica_id: ReplicaID) -> None:
"""Called whenever a deployment replica is being stopped."""
deployment_id = replica_id.deployment_id
self._pending_replicas[deployment_id].pop(replica_id, None)
self._launching_replicas[deployment_id].pop(replica_id, None)
self._recovering_replicas[deployment_id].discard(replica_id)
self._running_replicas[deployment_id].pop(replica_id, None)
self._logged_pack_placement_failures.discard(replica_id)
def on_replica_running(self, replica_id: ReplicaID, node_id: str) -> None:
"""Called whenever a deployment replica is running with a known node id."""
deployment_id = replica_id.deployment_id
assert replica_id not in self._pending_replicas[deployment_id]
self._launching_replicas[deployment_id].pop(replica_id, None)
self._recovering_replicas[deployment_id].discard(replica_id)
self._running_replicas[deployment_id][replica_id] = node_id
def on_replica_recovering(self, replica_id: ReplicaID) -> None:
"""Called whenever a deployment replica is recovering."""
deployment_id = replica_id.deployment_id
assert replica_id not in self._pending_replicas[deployment_id]
assert replica_id not in self._launching_replicas[deployment_id]
assert replica_id not in self._running_replicas[deployment_id]
assert replica_id not in self._recovering_replicas[deployment_id]
self._recovering_replicas[deployment_id].add(replica_id)
def _on_replica_launching(
self,
replica_id: ReplicaID,
target_node_id: Optional[str] = None,
target_labels: Optional[Dict[str, Any]] = None,
):
deployment_id = replica_id.deployment_id
self._launching_replicas[deployment_id][replica_id] = LaunchingReplicaInfo(
target_node_id=target_node_id, target_labels=target_labels
)
def _get_node_to_running_replicas(
self, deployment_id: Optional[DeploymentID] = None
) -> Dict[str, Set[ReplicaID]]:
res = defaultdict(set)
if deployment_id:
for replica_id, node_id in self._running_replicas[deployment_id].items():
res[node_id].add(replica_id)
else:
for _, replicas in self._running_replicas.items():
for replica_id, node_id in replicas.items():
res[node_id].add(replica_id)
return res
def _get_available_resources_per_node(self) -> Dict[str, AvailableNodeResources]:
"""Gets current available resources per node.
This returns a conservative view of the available resources
currently in the cluster. It returns the minimum of:
1. The available resources per node fetched and cached from the
GCS every control loop.
2. The remaining resources left over on each node after
subtracting the resources taken up by running (already
scheduled by core) and launching (to-be-scheduled and soft
targeting that node) replicas.
Note that (1) may not be accurate because it uses cached info
and there is a delay from fetching data from GCS, and (2) may
not be accurate because there can be other actors (not replicas)
running in the cluster, and launching replicas may not end up on
the node we're targeting. So the information returned from this
method is only best effort.
"""
available_resources = (
self._cluster_node_info_cache.get_available_resources_per_node()
)
total_resources = self._cluster_node_info_cache.get_total_resources_per_node()
gcs_info = {
node_id: AvailableNodeResources(r)
for node_id, r in available_resources.items()
}
# Manually calculate available resources per node by subtracting
# launching and running replicas from total resources
total_minus_replicas = {
node_id: AvailableNodeResources(resources)
for node_id, resources in total_resources.items()
}
for deployment_id, replicas in self._launching_replicas.items():
deployment = self._deployments[deployment_id]
for info in replicas.values():
target_node_id = info.target_node_id
if not target_node_id or target_node_id not in total_minus_replicas:
continue
total_minus_replicas[target_node_id] -= deployment.required_resources
for deployment_id, replicas in self._running_replicas.items():
deployment = self._deployments[deployment_id]
for node_id in replicas.values():
if node_id not in total_minus_replicas:
continue
total_minus_replicas[node_id] -= deployment.required_resources
def custom_min(a: AvailableNodeResources, b: AvailableNodeResources):
keys = set(a.keys()) | set(b.keys())
res = AvailableNodeResources()
for key in keys:
res[key] = min(a.get(key), b.get(key))
return res
# Filter by active node ids (alive but not draining)
return {
node_id: custom_min(
gcs_info.get(node_id, AvailableNodeResources()),
total_minus_replicas.get(node_id, AvailableNodeResources()),
)
for node_id in self._cluster_node_info_cache.get_active_node_ids()
}
def _best_fit_node(
self,
required_resources: RequestedResources,
available_resources: Dict[str, AvailableNodeResources],
) -> Optional[str]:
"""Chooses a node using best fit strategy.
This strategy picks the node where, if the required resources
were to be scheduled on that node, it will leave the smallest
remaining space. This minimizes fragmentation of resources.
"""
min_remaining_space = None
chosen_node = None
for node_id in available_resources:
if not available_resources[node_id].can_fit(required_resources):
continue
# TODO(zcin): We can make this better by only considering
# custom resources that required_resources has.
remaining_space = available_resources[node_id] - required_resources
if min_remaining_space is None or remaining_space < min_remaining_space:
min_remaining_space = remaining_space
chosen_node = node_id
return chosen_node
@abstractmethod
def schedule(
self,
upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]],
downscales: Dict[DeploymentID, DeploymentDownscaleRequest],
) -> Dict[DeploymentID, Set[ReplicaID]]:
"""Called for each update cycle to do batch scheduling.
Args:
upscales: a dict of deployment name to a list of replicas to schedule.
downscales: a dict of deployment name to a downscale request.
Returns:
The name of replicas to stop for each deployment.
"""
raise NotImplementedError
def _schedule_replica(
self,
scheduling_request: ReplicaSchedulingRequest,
default_scheduling_strategy: str,
target_node_id: Optional[str] = None,
target_labels: Optional[LabelMatchExpressionsT] = None,
) -> bool:
"""Schedule a replica from a scheduling request.
The following special scheduling strategies will be used, in
order of highest to lowest priority.
1. If a replica requires gang scheduling, we will use a reserved
gang placement group.
2. If a replica requires placement groups, we will choose to use
a `PlacementGroupSchedulingStrategy`. This can also take a
target node into consideration (soft target), if provided.
However it cannot take into account target labels.
3. If a `target_node_id` is provided, we will choose to use a
`NodeAffinitySchedulingStrategy`.
4. If `target_labels` is provided, we will choose to use a
`NodeLabelSchedulingStrategy`.
Args:
scheduling_request: A request to schedule a replica.
default_scheduling_strategy: The scheduling strategy to fall
back to if no special scheduling strategy is necessary.
target_node_id: Attempt to schedule this replica onto this
target node.
target_labels: Attempt to schedule this replica onto nodes
with these target labels.
Returns:
True if the replica was successfully scheduled, False otherwise.
"""
replica_id = scheduling_request.replica_id
deployment_id = replica_id.deployment_id
placement_group = None
scheduling_strategy = default_scheduling_strategy
if scheduling_request.gang_placement_group is not None:
# Gang scheduling -- use the reserved gang placement group
placement_group = scheduling_request.gang_placement_group
scheduling_strategy = PlacementGroupSchedulingStrategy(
placement_group=placement_group,
placement_group_bundle_index=scheduling_request.gang_pg_index,
placement_group_capture_child_tasks=True,
)
# TODO (jeffreywang): Add support for target labels and node affinity
target_labels = None
target_node_id = None
elif scheduling_request.placement_group_bundles is not None:
placement_group_strategy = (
scheduling_request.placement_group_strategy
if scheduling_request.placement_group_strategy
else "PACK"
)
try:
pg = self._create_placement_group_fn(
CreatePlacementGroupRequest(
bundles=scheduling_request.placement_group_bundles,
strategy=placement_group_strategy,
target_node_id=target_node_id,
name=scheduling_request.actor_options["name"],
bundle_label_selector=scheduling_request.placement_group_bundle_label_selector,
)
)
except Exception:
# We add a defensive exception here, so the controller can
# make progress even if the placement group isn't created.
# See https://github.com/ray-project/ray/issues/43888.
logger.exception(
f"Failed to create a placement group for {replica_id}."
)
scheduling_request.status = (
ReplicaSchedulingRequestStatus.PLACEMENT_GROUP_CREATION_FAILED
)
return False
# Pin the actor as a subset of bundle 0. ReplicaConfig
# validates that actor resources fit in bundle 0, and
# required_resources assumes this pin.
scheduling_strategy = PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=0,
placement_group_capture_child_tasks=True,
)
target_labels = None
elif target_node_id is not None:
scheduling_strategy = NodeAffinitySchedulingStrategy(
node_id=target_node_id, soft=True, _spill_on_unavailable=True
)
target_labels = None
elif target_labels is not None:
scheduling_strategy = NodeLabelSchedulingStrategy(
hard={}, soft=target_labels
)
target_node_id = None
actor_options = copy.deepcopy(scheduling_request.actor_options)
if (
scheduling_request.max_replicas_per_node is not None
and scheduling_request.max_replicas_per_node > 0
):
if "resources" not in actor_options:
actor_options["resources"] = {}
# Using implicit resource (resources that every node
# implicitly has and total is 1)
# to limit the number of replicas on a single node.
actor_options["resources"][
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}"
f"{deployment_id.app_name}:{deployment_id.name}"
] = (1.0 / scheduling_request.max_replicas_per_node)
try:
actor_handle = scheduling_request.actor_def.options(
scheduling_strategy=scheduling_strategy,
**actor_options,
).remote(*scheduling_request.actor_init_args)
except Exception:
# We add a defensive exception here, so the controller can
# make progress even if the actor options are misconfigured.
logger.exception(f"Failed to create an actor for {replica_id}.")
scheduling_request.status = (
ReplicaSchedulingRequestStatus.ACTOR_CREATION_FAILED
)
return False
del self._pending_replicas[deployment_id][replica_id]
self._on_replica_launching(
replica_id, target_node_id=target_node_id, target_labels=target_labels
)
if isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy):
placement_group = scheduling_strategy.placement_group
scheduling_request.status = ReplicaSchedulingRequestStatus.SUCCEEDED
scheduling_request.on_scheduled(actor_handle, placement_group=placement_group)
return True
@abstractmethod
def get_node_to_compact(
self, allow_new_compaction: bool
) -> Optional[Tuple[str, float]]:
"""Returns a node ID to be compacted and a compaction deadlne."""
raise NotImplementedError
def schedule_gang_placement_groups(
self,
gang_requests: Dict[DeploymentID, GangPlacementGroupRequest],
) -> Dict[DeploymentID, GangReservationResult]:
"""Reserve gang placement groups for gang scheduling.
Creates gang placement groups before replicas are created, allowing
the scheduler to verify resource feasibility upfront.
Args:
gang_requests: A dictionary of deployment ID to gang placement group request.
Returns:
A dictionary of deployment ID to gang reservation result.
"""
return {
deployment_id: self._prepare_gangs_for_deployment(deployment_id, request)
for deployment_id, request in gang_requests.items()
}
def _prepare_gangs_for_deployment(
self,
deployment_id: DeploymentID,
request: GangPlacementGroupRequest,
) -> GangReservationResult:
"""Create gang placement groups for a single deployment.
Example:
- Case 1: Per-replica bundles are defined
gang_size=2, replica_placement_group_bundles=[{"GPU":1,"CPU":1},{"CPU":1}]
Requested gang placement group:
[{"GPU":1,"CPU":1}, {"CPU":1}, {"GPU":1,"CPU":1}, {"CPU":1}]
^^^^^^^ replica 0 ^^^^^^^^^^ ^^^^^^^^ replica 1 ^^^^^^^^^
Replica 0 actor → bundle index 0, replica 1 actor → bundle index 2.
Remaining bundles (1, 3) are used by child tasks/actors.
- Case 2: Per-replica bundles are not defined
gang_size=2, replica_resource_dict={"CPU":2,"GPU":1}
Requested gang placement group:
[{"CPU":2,"GPU":1}, {"CPU":2,"GPU":1}]
^^^ replica 0 ^^^ ^^^ replica 1 ^^^
Replica 0 actor → bundle index 0, replica 1 actor → bundle index 1.
Args:
deployment_id: The deployment to create gangs for.
request: Contains gang config and number of replicas to add.
Returns:
GangReservationResult with all created gang PGs.
"""
gang_size = request.gang_size
if request.num_replicas_to_add % gang_size != 0:
logger.error(
f"num_replicas_to_add {request.num_replicas_to_add} "
f"is not divisible by gang_size {gang_size}."
)
return GangReservationResult(
success=False,
error_message=(
f"num_replicas_to_add {request.num_replicas_to_add} "
f"is not divisible by gang_size {gang_size}."
),
)
num_gangs = request.num_replicas_to_add // gang_size
per_replica_bundles = request.replica_placement_group_bundles
has_pg_bundles = (
per_replica_bundles is not None and len(per_replica_bundles) > 0
)
# Flatten per-replica bundles to form a placement group to atomically reserve resources
# required for each gang
gang_pgs: List[PlacementGroup] = []
gang_ids: List[str] = []
gang_pg_names: List[str] = []
for gang_index in range(num_gangs):
if has_pg_bundles:
bundles = [
bundle.copy()
for _ in range(gang_size)
for bundle in per_replica_bundles
]
label_selector = (
[
selector.copy()
for _ in range(gang_size)
for selector in request.replica_pg_bundle_label_selector
]
if request.replica_pg_bundle_label_selector is not None
else None
)
fallback_strategy = (
[
strategy.copy()
for _ in range(gang_size)
for strategy in request.replica_pg_fallback_strategy
]
if request.replica_pg_fallback_strategy is not None
else None
)
else:
bundles = [
request.replica_resource_dict.copy() for _ in range(gang_size)
]
label_selector = None
fallback_strategy = None
gang_id = uuid.uuid4().hex[:8]
pg_name = (
f"{GANG_PG_NAME_PREFIX}{deployment_id.app_name}"
f"_{deployment_id.name}"
f"_{gang_index}_{gang_id}"
)
try:
pg = self._create_placement_group_fn(
CreatePlacementGroupRequest(
bundles=bundles,
strategy=request.gang_placement_strategy,
target_node_id=None,
name=pg_name,
bundle_label_selector=label_selector,
fallback_strategy=fallback_strategy,
)
)
gang_pgs.append(pg)
gang_ids.append(gang_id)
gang_pg_names.append(pg_name)
except Exception:
# Follow the same pattern as single-replica PG creation failure:
# log and skip this gang so the controller can make progress with
# the other gangs. The missing replicas will be retried on the next
# reconciliation loop.
logger.exception(
f"Failed to create gang placement group "
f"{gang_index} for {deployment_id}."
)
continue
if not gang_pgs:
return GangReservationResult(
success=False,
error_message=(
f"Failed to create any gang placement groups for {deployment_id}."
),
)
logger.info(
f"Created {len(gang_pgs)} of {num_gangs} gang PG(s) for "
f"{deployment_id}. Actors will wait for resource allocation."
)
return GangReservationResult(
success=True,
gang_pgs=gang_pgs,
gang_ids=gang_ids,
gang_pg_names=gang_pg_names,
)
class DefaultDeploymentScheduler(DeploymentScheduler):
def schedule(
self,
upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]],
downscales: Dict[DeploymentID, DeploymentDownscaleRequest],
) -> Dict[DeploymentID, Set[ReplicaID]]:
"""Called for each update cycle to do batch scheduling.
Args:
upscales: a dict of deployment name to a list of replicas to schedule.
downscales: a dict of deployment name to a downscale request.
Returns:
The IDs of replicas to stop for each deployment.
"""
# Update pending replicas from upscales.
for upscale in upscales.values():
for scheduling_request in upscale:
replica_id = scheduling_request.replica_id
deployment_id = replica_id.deployment_id
self._pending_replicas[deployment_id][replica_id] = scheduling_request
# Check for deprecated environment variable usage
if RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY:
warnings.warn(
"The environment variable 'RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY' "
"is deprecated and will be removed in a v2.55.0 release. "
"Please use 'RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY' instead.",
DeprecationWarning,
stacklevel=2,
)
# Determine scheduling strategy
non_strict_pack_pgs_exist = any(
d.is_non_strict_pack_pg() for d in self._deployments.values()
)
use_pack_strategy = (
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY and not non_strict_pack_pgs_exist
)
if use_pack_strategy:
# This branch is only reached if each deployment either:
# 1. Use STRICT_PACK placement group strategy, or
# 2. Do not use placement groups at all.
# This ensures Serve's best-fit node selection is respected by Ray Core
# (since _soft_target_node_id only works with STRICT_PACK).
self._schedule_with_pack_strategy()
else:
self._schedule_with_spread_strategy()
# Handle downscales
deployment_to_replicas_to_stop = {}
for downscale in downscales.values():
deployment_to_replicas_to_stop[
downscale.deployment_id
] = self._get_replicas_to_stop(
downscale.deployment_id,
downscale.num_to_stop,
gang_id_by_replica=downscale.gang_id_by_replica,
replicas_by_gang_id=downscale.replicas_by_gang_id,
gang_size=downscale.gang_size,
)
return deployment_to_replicas_to_stop
def _schedule_with_pack_strategy(self):
"""Tries to schedule pending replicas using PACK strategy."""
# Flatten dict of deployment replicas into all replicas,
# then sort by decreasing resource size
all_scheduling_requests = sorted(
_flatten(self._pending_replicas).values(),
key=lambda r: r.requested_resources,
reverse=True,
)
if all_scheduling_requests:
order_log_key = tuple(r.replica_id for r in all_scheduling_requests)
if order_log_key != self._last_pack_schedule_order_log_key:
self._last_pack_schedule_order_log_key = order_log_key
if Resources.CUSTOM_PRIORITY:
priority_desc = (
f"{Resources.CUSTOM_PRIORITY} "
f"(then GPU, CPU, memory, other custom)"
)
else:
priority_desc = (
"GPU, CPU, memory, other custom "
"(RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES unset)"
)
order_desc = ", ".join(
f"{r.replica_id.deployment_id.name}"
f"[{_format_resources_for_scheduling_log(r.requested_resources)}]"
for r in all_scheduling_requests
)
logger.info(
f"Pack scheduling {len(all_scheduling_requests)} pending "
f"replica(s). Resource priority: {priority_desc}. "
f"Schedule order (first scheduled first): {order_desc}."
)
# Fetch node labels for active nodes.
active_nodes = self._cluster_node_info_cache.get_active_node_ids()
all_node_labels = {
node_id: self._cluster_node_info_cache.get_node_labels(node_id)
for node_id in active_nodes
}
# Compute available resources once upfront and update incrementally
# as replicas are scheduled.
# Complexity is O(launching + running + nodes + requests * nodes).
available_resources_per_node = self._get_available_resources_per_node()
# Seed with running replicas; updated incrementally as replicas are
# scheduled so that newly occupied nodes are treated as non-idle.
node_to_assigned_replicas = self._get_node_to_running_replicas()
for scheduling_request in all_scheduling_requests:
target_node = self._pack_schedule_replica(
scheduling_request,
all_node_labels,
available_resources_per_node,
node_to_assigned_replicas,
)
# Incrementally update available resources for the target node.
# This is slightly conservative compared to recomputing from
# scratch (since we subtract from the min of GCS and calculated
# resources rather than only from the calculated side), but
# _get_available_resources_per_node is already best-effort.
if target_node and target_node in available_resources_per_node:
available_resources_per_node[target_node] = (
available_resources_per_node[target_node]
- scheduling_request.requested_resources
)
# Mark the node as non-idle so subsequent replicas prefer it.
if target_node and target_node not in node_to_assigned_replicas:
node_to_assigned_replicas[target_node] = set()
node_to_assigned_replicas[target_node].add(
scheduling_request.replica_id
)
def _schedule_with_spread_strategy(self):
"""Tries to schedule pending replicas using the SPREAD strategy."""
for pending_replicas in self._pending_replicas.values():
if not pending_replicas:
continue
for scheduling_request in list(pending_replicas.values()):
self._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="SPREAD",
)
def _pack_schedule_replica(
self,
scheduling_request: ReplicaSchedulingRequest,
all_node_labels: Dict[str, Dict[str, str]],
available_resources_per_node: Dict[str, AvailableNodeResources],
node_to_assigned_replicas: Dict[str, Set[ReplicaID]],
) -> Optional[str]:
"""Attempts to schedule a single request on the best available node.
Args:
scheduling_request: The replica scheduling request.
all_node_labels: Labels for all active nodes.
available_resources_per_node: Pre-computed available resources
per node.
node_to_assigned_replicas: Mapping of node IDs to replica IDs
(both running and newly scheduled in this batch).
Returns:
The target node ID if scheduling succeeded, None otherwise.
"""
placement_candidates = self._build_pack_placement_candidates(scheduling_request)
target_node = None
for required_resources, required_labels in placement_candidates:
target_node = self._find_best_fit_node_for_pack(
required_resources,
available_resources_per_node,
node_to_assigned_replicas,
required_labels_list=required_labels,
node_labels=all_node_labels,
)
if target_node:
break
replica_id = scheduling_request.replica_id
if target_node is None:
if replica_id not in self._logged_pack_placement_failures:
self._logged_pack_placement_failures.add(replica_id)
logger.info(
f"Pack scheduling could not place {replica_id} "
f"({_format_resources_for_scheduling_log(scheduling_request.requested_resources)}): "
f"no node with sufficient resources. "
f"Falling back to default scheduling."
)
succeeded = self._schedule_replica(
scheduling_request,
default_scheduling_strategy="DEFAULT",
target_node_id=target_node,
)
if succeeded and target_node is not None:
self._logged_pack_placement_failures.discard(replica_id)
logger.info(
f"Pack scheduled {replica_id} "
f"({_format_resources_for_scheduling_log(scheduling_request.requested_resources)}) "
f"onto node {target_node}."
)
elif not succeeded and target_node is not None:
if replica_id not in self._logged_pack_placement_failures:
self._logged_pack_placement_failures.add(replica_id)
logger.info(
f"Pack scheduling failed to launch {replica_id} "
f"({_format_resources_for_scheduling_log(scheduling_request.requested_resources)}) "
f"on node {target_node}."
)
return target_node if succeeded else None
def _build_pack_placement_candidates(
self, scheduling_request: ReplicaSchedulingRequest
) -> List[Tuple[RequestedResources, List[Dict[str, str]]]]:
"""Returns a list of (resources, labels) tuples to attempt for scheduling."""
# Collect a list of required resources and labels to try to schedule to
# support replica compaction when fallback strategies are provided.
placement_candidates = []
primary_labels = []
primary_bundles = scheduling_request.placement_group_bundles
if primary_bundles:
# PG: Use PG bundle_label_selector
if scheduling_request.placement_group_bundle_label_selector:
pg_strategy = scheduling_request.placement_group_strategy or None
if pg_strategy == "STRICT_PACK":
# All bundle_label_selectors must be satisfied on same node.
primary_labels = (
scheduling_request.placement_group_bundle_label_selector
)
else:
# TODO(ryanaoleary@): Support PACK strategy with bundle label selectors.
raise NotImplementedError(
"Placement Group strategy 'PACK' with bundle_label_selector "
"is not yet supported in the Serve scheduler."
)
else:
# Actor: Use Actor label selector
if "label_selector" in scheduling_request.actor_options:
primary_labels = [
scheduling_request.actor_options["label_selector"] or {}
]
# If PG is defined on scheduling request, then `requested_resources` represents the sum across all bundles.
placement_candidates.append(
(scheduling_request.requested_resources, primary_labels)
)
if scheduling_request.placement_group_fallback_strategy:
# TODO(ryanaoleary@): Add support for placement group fallback_strategy when it's added to options.
raise NotImplementedError(
"Placement Group fallback strategies are not yet supported in the Serve scheduler."
)
elif scheduling_request.actor_options.get("fallback_strategy"):
# Fallback strategy provided for Ray Actor.
for fallback in scheduling_request.actor_options["fallback_strategy"]:
fallback_labels = [fallback.get("label_selector", {}) or {}]
placement_candidates.append(
(scheduling_request.requested_resources, fallback_labels)
)
return placement_candidates
def _get_replicas_to_stop(
self,
deployment_id: DeploymentID,
max_num_to_stop: int,
gang_id_by_replica: Optional[Dict[ReplicaID, str]] = None,
replicas_by_gang_id: Optional[Dict[str, Set[ReplicaID]]] = None,
gang_size: Optional[int] = None,
) -> Set[ReplicaID]:
"""Select which replicas to stop for a downscale request in the following priority:
1. Prioritize replicas that are not in the RUNNING state.
2. Prioritize replicas not on the head node because we can't relinquish the head node.
3. Prioritize replicas on fallback nodes that don't match the label or bundle label selector.
4. Prioritize replicas on nodes with fewest total replicas so we can relinquish them.
5. Prioritize newer replicas over older replicas.
Note that this algorithm doesn't consider other non-serve actors on the same node.
See more at https://github.com/ray-project/ray/issues/20599.
For gang deployments, the same priority order is applied, but entire
gangs are selected atomically instead of individual replicas.
"""
replicas_priority: List[ReplicaID] = []
# Replicas not in running state don't have node id.
# We will prioritize those first.
replicas_priority.extend(
set().union(
self._pending_replicas[deployment_id].keys(),
self._launching_replicas[deployment_id].keys(),
self._recovering_replicas[deployment_id],
)
)
labels_to_check: List[Dict[str, str]] = []
if label_selector := self._deployments[deployment_id].label_selector:
labels_to_check.append(label_selector)
elif bundle_label_selector := self._deployments[
deployment_id
].bundle_label_selector:
labels_to_check.extend(bundle_label_selector)
node_to_running_replicas_of_all_deployments = (
self._get_node_to_running_replicas()
)
# _running_replicas preserves insertion order (oldest → newest).
# Reverse once so we have newest → oldest, then bucket by node.
ordered_running_replicas = list(self._running_replicas[deployment_id].items())
ordered_running_replicas.reverse()
ordered_running_replicas_of_target_deployment: Dict[
str, List[ReplicaID]
] = defaultdict(list)
for replica_id, replica_node_id in ordered_running_replicas:
ordered_running_replicas_of_target_deployment[replica_node_id].append(
replica_id
)
# Prioritize based on following priority:
# 1. Prioritize replicas not on the head node because we can't relinquish the head node.
# 2. Prioritize replicas on fallback nodes that don't match the label or bundle label selector.
# 3. Prioritize replicas on nodes with fewer total replicas so we can relinquish them.
def scale_down_priority(
node_and_replicas: Tuple[str, Set[ReplicaID]],
) -> Tuple[int, int, int]:
node_id, all_replicas = node_and_replicas
node_labels = self._cluster_node_info_cache.get_node_labels(node_id)
match_labels = not labels_to_check or any(
node_labels_match_selector(node_labels, labels)
for labels in labels_to_check
)
is_head_node = node_id == self._head_node_id
return int(is_head_node), int(match_labels), len(all_replicas)
for node_id, _ in sorted(
node_to_running_replicas_of_all_deployments.items(), key=scale_down_priority
):
if node_id not in ordered_running_replicas_of_target_deployment:
continue
# Newest-first list for this node.
for replica_id in ordered_running_replicas_of_target_deployment[node_id]:
replicas_priority.append(replica_id)
if gang_id_by_replica is not None:
# Gang scheduling is enabled: select entire gangs to stop atomically
replicas_to_stop: Set[ReplicaID] = set()
selected_gangs: Set[str] = set()
for replica_id in replicas_priority:
gang_id = gang_id_by_replica.get(replica_id)
if gang_id is None or gang_id in selected_gangs:
continue
if len(replicas_to_stop) + gang_size > max_num_to_stop:
break
selected_gangs.add(gang_id)
replicas_to_stop.update(replicas_by_gang_id[gang_id])
else:
# Single-replica scheduling: select individual replicas to stop
replicas_to_stop: Set[ReplicaID] = set()
for replica_id in replicas_priority:
replicas_to_stop.add(replica_id)
if len(replicas_to_stop) == max_num_to_stop:
break
return replicas_to_stop
def _filter_nodes_by_label_selector(
self,
available_nodes: Dict[str, AvailableNodeResources],
required_labels: Dict[str, str],
node_labels: Dict[str, Dict[str, str]],
) -> Dict[str, AvailableNodeResources]:
"""Filters available nodes based on label selector constraints."""
return {
node_id: resources
for node_id, resources in available_nodes.items()
if node_labels_match_selector(node_labels.get(node_id, {}), required_labels)
}
def _find_best_fit_node_for_pack(
self,
required_resources: RequestedResources,
available_resources_per_node: Dict[str, AvailableNodeResources],
node_to_assigned_replicas: Dict[str, Set[ReplicaID]],
required_labels_list: Optional[List[Dict[str, str]]] = None,
node_labels: Optional[Dict[str, Dict[str, str]]] = None,
) -> Optional[str]:
"""Chooses best available node to schedule the required resources.
If there are available nodes, returns the node ID of the best
available node, minimizing fragmentation. Prefers non-idle nodes
over idle nodes.
Args:
required_resources: Requested resources needed for this replica.
available_resources_per_node: Available resources per node.
node_to_assigned_replicas: Mapping of node IDs to replica IDs
(both running and newly scheduled in this batch).
required_labels_list: Label selectors to filter nodes.
node_labels: Labels for each node.
Returns:
The target node ID if scheduling succeeded, None otherwise.
"""
# Filter feasible nodes by provided label selectors if provided.
if required_labels_list and node_labels:
for required_labels in required_labels_list:
available_resources_per_node = self._filter_nodes_by_label_selector(
available_resources_per_node, required_labels, node_labels
)
if not available_resources_per_node:
return None
non_idle_nodes = {
node_id: res
for node_id, res in available_resources_per_node.items()
if len(node_to_assigned_replicas.get(node_id, set())) > 0
}
idle_nodes = {
node_id: res
for node_id, res in available_resources_per_node.items()
if len(node_to_assigned_replicas.get(node_id, set())) == 0
}
# 1. Prefer non-idle nodes
chosen_node = self._best_fit_node(required_resources, non_idle_nodes)
if chosen_node:
return chosen_node
# 2. Consider idle nodes last
chosen_node = self._best_fit_node(required_resources, idle_nodes)
if chosen_node:
return chosen_node
def get_node_to_compact(
self, allow_new_compaction: bool
) -> Optional[Tuple[str, float]]:
return None