3647 lines
118 KiB
Python
3647 lines
118 KiB
Python
import os
|
||
import sys
|
||
import time
|
||
|
||
# coding: utf-8
|
||
from typing import Dict, List, Optional, Tuple
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
import ray
|
||
from ray.autoscaler.v2.event_logger import AutoscalerEventLogger
|
||
from ray.autoscaler.v2.scheduler import (
|
||
NodeTypeConfig,
|
||
ResourceDemandScheduler,
|
||
ResourceRequestSource,
|
||
SchedulingNode,
|
||
SchedulingNodeStatus,
|
||
SchedulingReply,
|
||
SchedulingRequest,
|
||
logger,
|
||
)
|
||
from ray.autoscaler.v2.schema import (
|
||
AutoscalerInstance,
|
||
IPPRGroupSpec,
|
||
IPPRSpecs,
|
||
IPPRStatus,
|
||
NodeType,
|
||
)
|
||
from ray.autoscaler.v2.tests.util import MockEventLogger, make_autoscaler_instance
|
||
from ray.autoscaler.v2.utils import ResourceRequestUtil
|
||
from ray.core.generated.autoscaler_pb2 import (
|
||
ClusterResourceConstraint,
|
||
GangResourceRequest,
|
||
NodeState,
|
||
NodeStatus,
|
||
ResourceRequest,
|
||
)
|
||
from ray.core.generated.common_pb2 import LabelSelectorOperator
|
||
from ray.core.generated.instance_manager_pb2 import (
|
||
Instance,
|
||
NodeKind,
|
||
TerminationRequest,
|
||
)
|
||
|
||
ResourceMap = Dict[str, float]
|
||
|
||
logger.setLevel("DEBUG")
|
||
|
||
event_logger = AutoscalerEventLogger(MockEventLogger(logger))
|
||
|
||
|
||
def sched_request(
|
||
node_type_configs: Dict[NodeType, NodeTypeConfig],
|
||
max_num_nodes: Optional[int] = None,
|
||
resource_requests: Optional[List[ResourceRequest]] = None,
|
||
gang_resource_requests: Optional[List[List[ResourceRequest]]] = None,
|
||
cluster_resource_constraints: Optional[List[ResourceRequest]] = None,
|
||
instances: Optional[List[AutoscalerInstance]] = None,
|
||
idle_timeout_s: Optional[float] = None,
|
||
disable_launch_config_check: Optional[bool] = False,
|
||
ippr_specs: Optional[IPPRSpecs] = None,
|
||
ippr_statuses: Optional[Dict[str, IPPRStatus]] = None,
|
||
cloud_resource_availabilities: Optional[Dict[NodeType, float]] = None,
|
||
) -> SchedulingRequest:
|
||
|
||
if resource_requests is None:
|
||
resource_requests = []
|
||
if gang_resource_requests is None:
|
||
gang_resource_requests = []
|
||
if cluster_resource_constraints is None:
|
||
cluster_resource_constraints = []
|
||
if instances is None:
|
||
instances = []
|
||
if ippr_statuses is None:
|
||
ippr_statuses = {}
|
||
if cloud_resource_availabilities is None:
|
||
cloud_resource_availabilities = {}
|
||
|
||
return SchedulingRequest(
|
||
resource_requests=ResourceRequestUtil.group_by_count(resource_requests),
|
||
gang_resource_requests=[
|
||
GangResourceRequest(requests=reqs) for reqs in gang_resource_requests
|
||
],
|
||
cluster_resource_constraints=(
|
||
[
|
||
ClusterResourceConstraint(
|
||
resource_requests=ResourceRequestUtil.group_by_count(
|
||
cluster_resource_constraints
|
||
)
|
||
)
|
||
]
|
||
if cluster_resource_constraints
|
||
else []
|
||
),
|
||
current_instances=instances,
|
||
node_type_configs=node_type_configs,
|
||
max_num_nodes=max_num_nodes,
|
||
idle_timeout_s=idle_timeout_s,
|
||
disable_launch_config_check=disable_launch_config_check,
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses=ippr_statuses,
|
||
cloud_resource_availabilities=cloud_resource_availabilities,
|
||
)
|
||
|
||
|
||
def _launch_and_terminate(
|
||
reply: SchedulingReply,
|
||
) -> Tuple[Dict[NodeType, int], List[str]]:
|
||
actual_to_launch = {req.instance_type: req.count for req in reply.to_launch}
|
||
actual_to_terminate = [
|
||
(req.instance_id, req.ray_node_id, req.cause) for req in reply.to_terminate
|
||
]
|
||
|
||
return actual_to_launch, actual_to_terminate
|
||
|
||
|
||
def schedule(
|
||
node_type_configs: Dict[NodeType, NodeTypeConfig],
|
||
current_nodes_available_count: Dict,
|
||
resource_requests: List[Dict],
|
||
anti_affinity: bool = False,
|
||
max_nodes: Optional[int] = None,
|
||
cloud_resource_availabilities: Optional[Dict[NodeType, float]] = None,
|
||
) -> SchedulingReply:
|
||
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
instances: List[AutoscalerInstance] = []
|
||
for node_type, count in current_nodes_available_count.items():
|
||
for i in range(count):
|
||
instances.append(
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type=node_type,
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id=f"{node_type}-{i}",
|
||
node_id=f"r{i}{node_type}",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=f"r{i}{node_type}".encode("utf-8"),
|
||
ray_node_type_name=node_type,
|
||
available_resources=node_type_configs[node_type].resources,
|
||
total_resources=node_type_configs[node_type].resources,
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id=f"c-{node_type}-{i}",
|
||
)
|
||
)
|
||
|
||
if anti_affinity:
|
||
gang_requests = [
|
||
[
|
||
ResourceRequestUtil.make(r, [(ANTI_AFFINITY, "af", "af")])
|
||
for r in resource_requests
|
||
]
|
||
]
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=gang_requests,
|
||
max_num_nodes=max_nodes,
|
||
instances=instances,
|
||
cloud_resource_availabilities=cloud_resource_availabilities,
|
||
)
|
||
else:
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make(r) for r in resource_requests],
|
||
instances=instances,
|
||
max_num_nodes=max_nodes,
|
||
cloud_resource_availabilities=cloud_resource_availabilities,
|
||
)
|
||
return ResourceDemandScheduler(event_logger).schedule(request)
|
||
|
||
|
||
class TestSchedulingNode:
|
||
@staticmethod
|
||
def test_is_schedulable():
|
||
instance = make_autoscaler_instance(im_instance=None)
|
||
assert SchedulingNode.is_schedulable(instance) is False
|
||
|
||
all_im_status = set(Instance.InstanceStatus.values())
|
||
positive_statuses = {
|
||
Instance.QUEUED,
|
||
Instance.REQUESTED,
|
||
Instance.ALLOCATED,
|
||
Instance.RAY_INSTALLING,
|
||
Instance.RAY_RUNNING,
|
||
Instance.RAY_STOP_REQUESTED,
|
||
}
|
||
negative_statues = {
|
||
Instance.UNKNOWN,
|
||
Instance.RAY_STOPPING,
|
||
Instance.RAY_STOPPED,
|
||
Instance.TERMINATING,
|
||
Instance.TERMINATED,
|
||
Instance.ALLOCATION_FAILED,
|
||
Instance.RAY_INSTALL_FAILED,
|
||
Instance.TERMINATION_FAILED,
|
||
Instance.ALLOCATION_TIMEOUT,
|
||
}
|
||
for status in all_im_status:
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(instance_type="type_1", status=status)
|
||
)
|
||
|
||
if status in positive_statuses:
|
||
assert SchedulingNode.is_schedulable(instance) is True
|
||
elif status in negative_statues:
|
||
assert SchedulingNode.is_schedulable(instance) is False
|
||
else:
|
||
assert False, f"Unknown status {status}"
|
||
|
||
@staticmethod
|
||
@pytest.mark.parametrize(
|
||
"disable_launch_config_check", [True, False], ids=["disabled", "enabled"]
|
||
)
|
||
def test_new_node(disable_launch_config_check):
|
||
# Assert none IM instance.
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
labels={"foo": "foo"},
|
||
),
|
||
}
|
||
instance = make_autoscaler_instance(im_instance=None)
|
||
assert (
|
||
SchedulingNode.new(instance, node_type_configs, disable_launch_config_check)
|
||
is None
|
||
)
|
||
|
||
# A running ray node
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
dynamic_labels={"foo": "bar"},
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance, node_type_configs, disable_launch_config_check
|
||
)
|
||
assert node is not None
|
||
assert node.node_type == "type_1"
|
||
assert node.status == SchedulingNodeStatus.SCHEDULABLE
|
||
assert node.ray_node_id == "r1"
|
||
assert node.im_instance_id == "1"
|
||
assert node.available_resources_for_sched == {
|
||
ResourceRequestSource.PENDING_DEMAND: {"CPU": 0},
|
||
ResourceRequestSource.CLUSTER_RESOURCE_CONSTRAINT: {"CPU": 1},
|
||
}
|
||
assert node.total_resources == {"CPU": 1}
|
||
assert node.labels == {"foo": "bar"}
|
||
|
||
# A outdated node.
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_no_longer_exists",
|
||
status=Instance.REQUESTED,
|
||
instance_id="1",
|
||
),
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance, node_type_configs, disable_launch_config_check
|
||
)
|
||
if not disable_launch_config_check:
|
||
assert node is not None
|
||
assert node.node_type == "type_no_longer_exists"
|
||
assert node.status == SchedulingNodeStatus.TO_TERMINATE
|
||
assert node.termination_request is not None
|
||
assert node.termination_request.cause == TerminationRequest.Cause.OUTDATED
|
||
else:
|
||
assert node is None
|
||
|
||
# A pending ray node
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.REQUESTED,
|
||
instance_id="1",
|
||
)
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance, node_type_configs, disable_launch_config_check
|
||
)
|
||
assert node is not None
|
||
assert node.node_type == "type_1"
|
||
assert node.status == SchedulingNodeStatus.SCHEDULABLE
|
||
assert node.available_resources_for_sched == {
|
||
ResourceRequestSource.PENDING_DEMAND: {"CPU": 1},
|
||
ResourceRequestSource.CLUSTER_RESOURCE_CONSTRAINT: {"CPU": 1},
|
||
}
|
||
assert node.total_resources == {"CPU": 1}
|
||
assert node.labels == {"foo": "foo"}
|
||
|
||
@staticmethod
|
||
def test_new_running_instance_without_ray_node_returns_none():
|
||
# Regression test: a RAY_RUNNING instance whose ray_node is missing in
|
||
# GCS used to crash SchedulingNode.new with an AssertionError. The
|
||
# defensive guard returns None instead.
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
instance = make_autoscaler_instance(
|
||
ray_node=None,
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-stale",
|
||
node_id="r-gone",
|
||
),
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance,
|
||
node_type_configs,
|
||
disable_launch_config_check=False,
|
||
)
|
||
assert node is None
|
||
|
||
@staticmethod
|
||
def test_new_head_node():
|
||
# An allocated head node.
|
||
node_type_configs = {
|
||
"head": NodeTypeConfig(
|
||
name="head",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="head",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="1",
|
||
node_kind=NodeKind.HEAD,
|
||
)
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance, node_type_configs, disable_launch_config_check=False
|
||
)
|
||
assert node is not None
|
||
# It's important to check if the node is a head node
|
||
assert node.node_kind == NodeKind.HEAD
|
||
assert node.status == SchedulingNodeStatus.SCHEDULABLE
|
||
|
||
# An running head node.
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="head",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="head",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
node_kind=NodeKind.HEAD,
|
||
),
|
||
)
|
||
node = SchedulingNode.new(
|
||
instance, node_type_configs, disable_launch_config_check=False
|
||
)
|
||
assert node is not None
|
||
assert node.node_kind == NodeKind.HEAD
|
||
assert node.status == SchedulingNodeStatus.SCHEDULABLE
|
||
|
||
|
||
def test_min_worker_nodes():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=1,
|
||
max_worker_nodes=10,
|
||
),
|
||
"type_2": NodeTypeConfig(
|
||
name="type_2",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"type_3": NodeTypeConfig(
|
||
name="type_3",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
# With empty cluster
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
expected_to_launch = {"type_1": 1, "type_3": 2}
|
||
reply = scheduler.schedule(request)
|
||
actual_to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_launch) == sorted(expected_to_launch)
|
||
|
||
# With existing ray nodes
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1", status=Instance.RAY_RUNNING
|
||
),
|
||
ray_node=NodeState(ray_node_type_name="type_1"),
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1", status=Instance.RAY_RUNNING
|
||
),
|
||
ray_node=NodeState(ray_node_type_name="type_1"),
|
||
),
|
||
],
|
||
)
|
||
|
||
expected_to_launch = {"type_3": 2}
|
||
reply = scheduler.schedule(request)
|
||
actual_to_launch, _ = _launch_and_terminate(reply)
|
||
assert actual_to_launch == expected_to_launch
|
||
|
||
# With existing instances pending.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(instance_type="type_1", status=Instance.REQUESTED)
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(instance_type="type_1", status=Instance.ALLOCATED)
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_no_longer_exists",
|
||
status=Instance.REQUESTED,
|
||
instance_id="0",
|
||
)
|
||
),
|
||
],
|
||
)
|
||
expected_to_launch = {"type_3": 2}
|
||
reply = scheduler.schedule(request)
|
||
actual_to_launch, _ = _launch_and_terminate(reply)
|
||
assert actual_to_launch == expected_to_launch
|
||
|
||
|
||
def test_max_workers_head_node_type():
|
||
scheduler = ResourceDemandScheduler()
|
||
node_type_configs = {
|
||
"head_type": NodeTypeConfig(
|
||
name="head_type",
|
||
resources={},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=2,
|
||
)
|
||
}
|
||
instances = [
|
||
# A head node
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="head_type",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="0",
|
||
node_kind=NodeKind.HEAD,
|
||
),
|
||
),
|
||
# A worker node
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="head_type",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="1",
|
||
node_kind=NodeKind.WORKER,
|
||
),
|
||
),
|
||
# A worker node
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="head_type",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="2",
|
||
node_kind=NodeKind.WORKER,
|
||
),
|
||
),
|
||
]
|
||
|
||
request = sched_request(node_type_configs=node_type_configs, instances=instances)
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert len(actual_to_terminate) == 1
|
||
assert actual_to_terminate[0][0] in ["1", "2"]
|
||
assert actual_to_terminate[0][2] == TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE
|
||
|
||
|
||
def test_max_workers_per_type():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=2,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
expected_to_terminate = []
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(expected_to_terminate)
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1", status=Instance.ALLOCATED, instance_id="0"
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 0.5},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r2",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="2",
|
||
node_id="r2",
|
||
),
|
||
),
|
||
]
|
||
|
||
# 3 running instances with max of 2 allowed for type 1.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert actual_to_terminate == [
|
||
("0", "", TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE)
|
||
]
|
||
|
||
# 3 running instances with max of 1 allowed for type 1.
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(
|
||
[
|
||
("0", "", TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE),
|
||
# Lower resource util.
|
||
(
|
||
"1",
|
||
"r1",
|
||
TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
||
),
|
||
]
|
||
)
|
||
|
||
|
||
def test_terminate_max_allocated_workers_per_type():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=2,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
# No instances created, no-op.
|
||
expected_to_terminate = []
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(expected_to_terminate)
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_0",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r0",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="0",
|
||
node_id="r0",
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
]
|
||
|
||
# 2 nodes in allocated state with max of 2 allowed for type 1.
|
||
# Scheduler should leave all of the allocated instances.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert actual_to_terminate == []
|
||
|
||
# Max nodes is now 0 for type 1, scheduler should terminate
|
||
# both allocated instances to conform with max num nodes per type.
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=0,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(
|
||
[
|
||
(
|
||
"0",
|
||
"",
|
||
TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE,
|
||
), # allocated instance
|
||
("1", "", TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE),
|
||
]
|
||
)
|
||
|
||
|
||
def test_max_num_nodes():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=2,
|
||
),
|
||
"type_2": NodeTypeConfig(
|
||
name="type_2",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=2,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
max_num_nodes=1,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
expected_to_terminate = []
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(expected_to_terminate)
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1", status=Instance.ALLOCATED, instance_id="0"
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
idle_duration_ms=10,
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_2",
|
||
available_resources={"CPU": 0.5},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r2",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_2",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="2",
|
||
node_id="r2",
|
||
),
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_2",
|
||
available_resources={"CPU": 0.0},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r3",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_2",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="3",
|
||
node_id="r3",
|
||
),
|
||
),
|
||
]
|
||
|
||
# 4 running with 4 max => no termination
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
max_num_nodes=4,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert actual_to_terminate == []
|
||
|
||
# 4 running with 3 max => terminate 1
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
max_num_nodes=3,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
# Terminate one non-ray running first.
|
||
assert actual_to_terminate == [("0", "", TerminationRequest.Cause.MAX_NUM_NODES)]
|
||
|
||
# 4 running with 2 max => terminate 2
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
max_num_nodes=2,
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
# Terminate one non-ray running first.
|
||
assert sorted(actual_to_terminate) == sorted(
|
||
[
|
||
("0", "", TerminationRequest.Cause.MAX_NUM_NODES), # non-ray running
|
||
("1", "r1", TerminationRequest.Cause.MAX_NUM_NODES), # idle
|
||
]
|
||
)
|
||
|
||
# 4 running with 1 max => terminate 3
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
max_num_nodes=1,
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(
|
||
[
|
||
("0", "", TerminationRequest.Cause.MAX_NUM_NODES), # non-ray running
|
||
("1", "r1", TerminationRequest.Cause.MAX_NUM_NODES), # idle
|
||
("2", "r2", TerminationRequest.Cause.MAX_NUM_NODES), # less resource util
|
||
]
|
||
)
|
||
|
||
# Combine max_num_nodes with max_num_nodes_per_type
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=2,
|
||
),
|
||
"type_2": NodeTypeConfig(
|
||
name="type_2",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=0,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=instances,
|
||
max_num_nodes=1,
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
_, actual_to_terminate = _launch_and_terminate(reply)
|
||
assert sorted(actual_to_terminate) == sorted(
|
||
[
|
||
("0", "", TerminationRequest.Cause.MAX_NUM_NODES), # non-ray running
|
||
("2", "r2", TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE), # type-2
|
||
("3", "r3", TerminationRequest.Cause.MAX_NUM_NODE_PER_TYPE), # type-2
|
||
]
|
||
)
|
||
|
||
|
||
def test_single_resources():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Request 1 CPU should start a node.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({"type_1": 1})
|
||
|
||
# Request multiple CPUs should start multiple nodes
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})] * 3,
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({"type_1": 3})
|
||
|
||
# Request resources with already existing nodes should not launch new nodes.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({})
|
||
|
||
# Request resources with already existing nodes not sufficient should launch
|
||
# new nodes.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 0.9},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({"type_1": 1})
|
||
|
||
# Request resources with already pending nodes should NOT launch new nodes
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1", status=Instance.REQUESTED, instance_id="0"
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({})
|
||
|
||
|
||
def test_implicit_resources():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
implicit_resource = ray._raylet.IMPLICIT_RESOURCE_PREFIX + "a"
|
||
|
||
# implicit resources should scale up clusters.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({implicit_resource: 1})],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_1": 1})
|
||
|
||
# implicit resources should be satisfied by existing node.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[
|
||
ResourceRequestUtil.make({implicit_resource: 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
],
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
|
||
|
||
def test_max_worker_num_enforce_with_resource_requests():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
max_num_nodes = 2
|
||
|
||
# Request 10 CPUs should start at most 2 nodes.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
max_num_nodes=max_num_nodes,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})] * 3,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_lauch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_lauch) == sorted({"type_1": 1})
|
||
|
||
|
||
def test_multi_requests_fittable():
|
||
"""
|
||
Test multiple requests can be fit into a single node.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
"type_2": NodeTypeConfig(
|
||
name="type_2",
|
||
resources={"CPU": 3},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1, "GPU": 1}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_1": 1, "type_2": 1})
|
||
assert reply.infeasible_resource_requests == []
|
||
|
||
# Change the ordering of requests should not affect the result.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[
|
||
ResourceRequestUtil.make({"CPU": 1, "GPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_1": 1, "type_2": 1})
|
||
assert reply.infeasible_resource_requests == []
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[
|
||
ResourceRequestUtil.make({"CPU": 2}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 0.5, "GPU": 0.5}),
|
||
ResourceRequestUtil.make({"CPU": 0.5, "GPU": 0.5}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_1": 1, "type_2": 1})
|
||
assert reply.infeasible_resource_requests == []
|
||
|
||
# However, if we already have fragmentation. We should not be able
|
||
# to fit more requests.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1, "GPU": 1}),
|
||
],
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 0, "GPU": 1},
|
||
total_resources={"CPU": 1, "GPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_2": 1})
|
||
assert len(reply.infeasible_resource_requests) == 1
|
||
|
||
|
||
def test_multi_node_types_score():
|
||
"""
|
||
Test that when multiple node types are possible, choose the best scoring ones:
|
||
1. The number of resources utilized.
|
||
2. The amount of utilization.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_large": NodeTypeConfig(
|
||
name="type_large",
|
||
resources={"CPU": 10}, # Large machines
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
"type_small": NodeTypeConfig(
|
||
name="type_small",
|
||
resources={"CPU": 5},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
"type_gpu": NodeTypeConfig(
|
||
name="type_gpu",
|
||
resources={"CPU": 2, "GPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
# Request 1 CPU should just start the small machine and not the GPU machine
|
||
# since it has more types of resources.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_small": 1})
|
||
|
||
# type_small should be preferred over type_large.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_small": 1})
|
||
|
||
|
||
def test_multi_node_types_score_with_gpu(monkeypatch):
|
||
"""
|
||
Test that when multiple node types are possible, choose the best scoring ones:
|
||
- The GPU scoring.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_gpu": NodeTypeConfig(
|
||
name="type_gpu",
|
||
resources={"CPU": 1, "GPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
"type_multi": NodeTypeConfig(
|
||
name="type_multi",
|
||
resources={"CPU": 2, "XXX": 2}, # Some random resource.
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_multi": 1})
|
||
|
||
with monkeypatch.context() as m:
|
||
m.setattr(ray.autoscaler.v2.scheduler, "AUTOSCALER_CONSERVE_GPU_NODES", 0)
|
||
# type_multi should now be preferred over type_gpu.
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_gpu": 1})
|
||
|
||
|
||
def test_resource_constrains():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_cpu": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=1,
|
||
max_worker_nodes=5,
|
||
),
|
||
"type_gpu": NodeTypeConfig(
|
||
name="type_gpu",
|
||
resources={"CPU": 1, "GPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
# Resource constraints should not launch extra with min_nodes
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
cluster_resource_constraints=[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_cpu": 1})
|
||
|
||
# Constraints should launch extra nodes.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
cluster_resource_constraints=[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"GPU": 1}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_cpu": 1, "type_gpu": 1})
|
||
|
||
# Resource constraints should not launch extra with max_nodes
|
||
# fails to atomically ensure constraints.
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
cluster_resource_constraints=[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"GPU": 2}),
|
||
ResourceRequestUtil.make({"GPU": 2}),
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted({"type_cpu": 1})
|
||
assert len(reply.infeasible_cluster_resource_constraints) == 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"disable_launch_config_check", [True, False], ids=["disabled", "enabled"]
|
||
)
|
||
def test_outdated_nodes(disable_launch_config_check):
|
||
"""
|
||
Test that nodes with outdated node configs are terminated.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_cpu": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=5,
|
||
launch_config_hash="hash1",
|
||
),
|
||
"head_node": NodeTypeConfig(
|
||
name="head_node",
|
||
resources={"CPU": 0},
|
||
launch_config_hash="hash2",
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
disable_launch_config_check=disable_launch_config_check,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_cpu",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash2",
|
||
instance_id="i-1",
|
||
node_id="r-1",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_cpu",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r-1",
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_cpu",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1", # matched
|
||
instance_id="i-2",
|
||
node_id="r-2",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_cpu",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r-2",
|
||
),
|
||
cloud_instance_id="c-2",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="head_node",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1", # mismatched -> but don't terminate
|
||
instance_id="i-3",
|
||
node_kind=NodeKind.HEAD,
|
||
node_id="r-3",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="head_node",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 0},
|
||
node_id=b"r-3",
|
||
),
|
||
cloud_instance_id="c-3",
|
||
),
|
||
],
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, to_terminate = _launch_and_terminate(reply)
|
||
if not disable_launch_config_check:
|
||
assert to_terminate == [("i-1", "r-1", TerminationRequest.Cause.OUTDATED)]
|
||
assert to_launch == {"type_cpu": 1} # Launch 1 to replace the outdated node.
|
||
else:
|
||
assert to_terminate == []
|
||
assert to_launch == {}
|
||
|
||
|
||
@pytest.mark.parametrize("idle_timeout_s", [1, 2, 10])
|
||
@pytest.mark.parametrize("has_resource_constraints", [True, False])
|
||
@pytest.mark.parametrize("has_resource_requests", [True, False])
|
||
@pytest.mark.parametrize("has_gang_resource_requests", [True, False])
|
||
def test_idle_termination(
|
||
idle_timeout_s,
|
||
has_resource_constraints,
|
||
has_resource_requests,
|
||
has_gang_resource_requests,
|
||
):
|
||
"""
|
||
Test that idle nodes are terminated.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_cpu": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=5,
|
||
launch_config_hash="hash1",
|
||
),
|
||
"head_node": NodeTypeConfig(
|
||
name="head_node",
|
||
resources={"CPU": 0},
|
||
launch_config_hash="hash2",
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
idle_time_s = 5
|
||
constraints = []
|
||
if has_resource_constraints:
|
||
constraints = [ResourceRequestUtil.make({"CPU": 1})] * 2
|
||
|
||
resource_requests = []
|
||
if has_resource_requests:
|
||
resource_requests = [ResourceRequestUtil.make({"CPU": 1})] * 2
|
||
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
gang_resource_requests = []
|
||
if has_gang_resource_requests:
|
||
gang_resource_requests = [
|
||
[ # This is a strict spread placement group that requires 2 nodes.
|
||
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "pg", "")]),
|
||
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "pg", "")]),
|
||
]
|
||
]
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_cpu",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
instance_id="i-1",
|
||
node_id="r-1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=b"r-1",
|
||
ray_node_type_name="type_cpu",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 1},
|
||
idle_duration_ms=0, # Non idle
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_id="i-2",
|
||
instance_type="type_cpu",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
node_id="r-2",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_cpu",
|
||
node_id=b"r-2",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
idle_duration_ms=idle_time_s * 1000,
|
||
status=NodeStatus.IDLE,
|
||
),
|
||
cloud_instance_id="c-2",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_id="i-3",
|
||
instance_type="head_node",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash2",
|
||
node_kind=NodeKind.HEAD,
|
||
node_id="r-3",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="head_node",
|
||
node_id=b"r-3",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 0},
|
||
idle_duration_ms=999 * 1000, # idle
|
||
status=NodeStatus.IDLE,
|
||
),
|
||
cloud_instance_id="c-3",
|
||
),
|
||
],
|
||
idle_timeout_s=idle_timeout_s,
|
||
cluster_resource_constraints=constraints,
|
||
resource_requests=resource_requests,
|
||
gang_resource_requests=gang_resource_requests,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, to_terminate = _launch_and_terminate(reply)
|
||
if (
|
||
idle_timeout_s <= idle_time_s
|
||
and not has_resource_constraints
|
||
and not has_resource_requests
|
||
and not has_gang_resource_requests
|
||
):
|
||
assert len(to_terminate) == 1
|
||
assert to_terminate == [("i-2", "r-2", TerminationRequest.Cause.IDLE)]
|
||
else:
|
||
assert len(to_terminate) == 0
|
||
|
||
|
||
@pytest.mark.parametrize("min_workers", [0, 1])
|
||
def test_idle_termination_with_min_worker(min_workers):
|
||
"""
|
||
Test that idle nodes are terminated.
|
||
"""
|
||
idle_timeout_s = 1
|
||
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_cpu": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=min_workers,
|
||
max_worker_nodes=5,
|
||
launch_config_hash="hash1",
|
||
),
|
||
"head_node": NodeTypeConfig(
|
||
name="head_node",
|
||
resources={"CPU": 0},
|
||
launch_config_hash="hash2",
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
idle_time_s = 5
|
||
constraints = []
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_id="i-1",
|
||
instance_type="type_cpu",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
node_id="r-1",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_cpu",
|
||
node_id=b"r-1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
idle_duration_ms=idle_time_s * 1000,
|
||
status=NodeStatus.IDLE,
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_id="i-2",
|
||
instance_type="head_node",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash2",
|
||
node_kind=NodeKind.HEAD,
|
||
node_id="r-2",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="head_node",
|
||
node_id=b"r-2",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 0},
|
||
idle_duration_ms=999 * 1000, # idle
|
||
status=NodeStatus.IDLE,
|
||
),
|
||
cloud_instance_id="c-2",
|
||
),
|
||
],
|
||
idle_timeout_s=idle_timeout_s,
|
||
cluster_resource_constraints=constraints,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, to_terminate = _launch_and_terminate(reply)
|
||
assert idle_timeout_s <= idle_time_s
|
||
if min_workers == 0:
|
||
assert len(to_terminate) == 1
|
||
assert to_terminate == [("i-1", "r-1", TerminationRequest.Cause.IDLE)]
|
||
else:
|
||
assert min_workers > 0
|
||
assert len(to_terminate) == 0
|
||
|
||
|
||
@pytest.mark.parametrize("node_type_idle_timeout_s", [1, 2, 10])
|
||
def test_idle_termination_with_node_type_idle_timeout(node_type_idle_timeout_s):
|
||
"""
|
||
Test that idle nodes are terminated when idle_timeout_s is set for node type.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_cpu_with_idle_timeout": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=5,
|
||
idle_timeout_s=node_type_idle_timeout_s,
|
||
launch_config_hash="hash1",
|
||
),
|
||
}
|
||
|
||
idle_time_s = 5
|
||
constraints = []
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
instances=[
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_cpu_with_idle_timeout",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
instance_id="i-1",
|
||
node_id="r-1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=b"r-1",
|
||
ray_node_type_name="type_cpu_with_idle_timeout",
|
||
available_resources={"CPU": 0},
|
||
total_resources={"CPU": 1},
|
||
idle_duration_ms=0, # Non idle
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_id="i-2",
|
||
instance_type="type_cpu_with_idle_timeout",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
node_id="r-2",
|
||
),
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_cpu_with_idle_timeout",
|
||
node_id=b"r-2",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
idle_duration_ms=idle_time_s * 1000,
|
||
status=NodeStatus.IDLE,
|
||
),
|
||
cloud_instance_id="c-2",
|
||
),
|
||
],
|
||
# Set autoscaler idle_timeout_s to a value greater than
|
||
# node_type_idle_timeout_s and idle_time_s.
|
||
idle_timeout_s=idle_time_s * 1000,
|
||
cluster_resource_constraints=constraints,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
_, to_terminate = _launch_and_terminate(reply)
|
||
if node_type_idle_timeout_s <= idle_time_s:
|
||
assert len(to_terminate) == 1
|
||
assert to_terminate == [("i-2", "r-2", TerminationRequest.Cause.IDLE)]
|
||
else:
|
||
assert len(to_terminate) == 0
|
||
|
||
|
||
def test_gang_scheduling():
|
||
"""
|
||
Test that gang scheduling works.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
AFFINITY = ResourceRequestUtil.PlacementConstraintType.AFFINITY
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
|
||
node_type_configs = {
|
||
"type_cpu": NodeTypeConfig(
|
||
name="type_cpu",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=5,
|
||
launch_config_hash="hash1",
|
||
)
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[
|
||
[
|
||
ResourceRequestUtil.make({"CPU": 1}, [(AFFINITY, "pg", "")]),
|
||
ResourceRequestUtil.make({"CPU": 1}, [(AFFINITY, "pg", "")]),
|
||
]
|
||
],
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# Should be grouped on the same node.
|
||
assert sorted(to_launch) == sorted({"type_cpu": 1})
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[
|
||
[
|
||
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "pg", "")]),
|
||
ResourceRequestUtil.make({"CPU": 1}, [(ANTI_AFFINITY, "pg", "")]),
|
||
]
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# Should be placed on different nodes.
|
||
assert sorted(to_launch) == sorted({"type_cpu": 2})
|
||
|
||
# Atomic gang scheduling
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[
|
||
[
|
||
# Couldn't fit on a node.
|
||
ResourceRequestUtil.make({"CPU": 3}, [(AFFINITY, "pg", "")]),
|
||
ResourceRequestUtil.make({"CPU": 3}, [(AFFINITY, "pg", "")]),
|
||
]
|
||
],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
assert len(reply.infeasible_gang_resource_requests) == 1
|
||
|
||
|
||
def test_gang_scheduling_with_others():
|
||
"""
|
||
Test that a mix of the various demands:
|
||
- resource requests from tasks/actors
|
||
- gang requests from placement groups
|
||
- cluster resource constraints
|
||
- min/max worker counts
|
||
- existing nodes.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 4},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=4,
|
||
launch_config_hash="hash1",
|
||
),
|
||
"type_2": NodeTypeConfig(
|
||
name="type_2",
|
||
resources={"CPU": 1, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
launch_config_hash="hash2",
|
||
),
|
||
}
|
||
|
||
# Placement constraints
|
||
AFFINITY = ResourceRequestUtil.PlacementConstraintType.AFFINITY
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
gang_requests = [
|
||
[
|
||
ResourceRequestUtil.make({"CPU": 2}, [(ANTI_AFFINITY, "ak", "av")]),
|
||
ResourceRequestUtil.make({"CPU": 2}, [(ANTI_AFFINITY, "ak", "av")]),
|
||
ResourceRequestUtil.make({"CPU": 2}, [(ANTI_AFFINITY, "ak", "av")]),
|
||
ResourceRequestUtil.make({"CPU": 2}, [(ANTI_AFFINITY, "ak", "av")]),
|
||
],
|
||
[
|
||
ResourceRequestUtil.make({"CPU": 3}, [(AFFINITY, "c", "c1")]),
|
||
ResourceRequestUtil.make({"CPU": 3}, [(AFFINITY, "c", "c1")]),
|
||
],
|
||
[
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
ResourceRequestUtil.make({"CPU": 1}),
|
||
],
|
||
]
|
||
|
||
# Resource requests
|
||
resource_requests = [
|
||
ResourceRequestUtil.make({"CPU": 2}),
|
||
ResourceRequestUtil.make({"GPU": 1, "CPU": 1}),
|
||
ResourceRequestUtil.make({"GPU": 1}),
|
||
]
|
||
|
||
# Cluster constraints
|
||
cluster_constraints = [ResourceRequestUtil.make({"CPU": 1})] * 10
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash1",
|
||
instance_id="i-1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=b"r-1",
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 2},
|
||
total_resources={"CPU": 4},
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_2",
|
||
status=Instance.RAY_RUNNING,
|
||
launch_config_hash="hash2",
|
||
instance_id="i-2",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=b"r-2",
|
||
ray_node_type_name="type_2",
|
||
available_resources={"CPU": 1, "GPU": 1},
|
||
total_resources={"CPU": 1, "GPU": 1},
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id="c-2",
|
||
),
|
||
]
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=gang_requests,
|
||
resource_requests=resource_requests,
|
||
cluster_resource_constraints=cluster_constraints,
|
||
instances=instances,
|
||
idle_timeout_s=999,
|
||
)
|
||
# Calculate the expected number of nodes to launch:
|
||
# - 1 type_1, 1 type_2 to start with => CPU: 2/5, GPU: 1/1
|
||
# - added 1 type_1 for minimal request -> +1 type_1
|
||
# ==> 2 type_1, 1 type_2 (CPU: 6/9, GPU: 1/1)
|
||
# - enforce cluster constraint (10 CPU) -> +1 type_1, CPU: 10/13, GPU: 1/1
|
||
# ==> 3 type_1, 1 type_2 (CPU: 10/13, GPU: 1/1)
|
||
# - sched gang requests:
|
||
# - anti affinity (8CPU) => +1 type_1, CPU: 6/17, GPU: 1/1
|
||
# - no constraint (3CPU) => CPU: 3/17, GPU: 1/1
|
||
# - affinity (not feasible)
|
||
# ==> 4 type_1, 1 type_2 (CPU: 3/17, GPU: 1/1)
|
||
# - sched resource requests:
|
||
# - 2CPU => CPU: 1/17, GPU: 1/1
|
||
# - 1GPU, 1CPU => CPU: 0/17, GPU: 0/1
|
||
# - 1GPU => adding a new type_2
|
||
# ==> 4 type_1, 2 type_2 (CPU: 0/17, GPU: 0/2)
|
||
# Therefore:
|
||
# - added nodes: 3 type_1, 1 type_2
|
||
# - infeasible: 1 gang request, 1 resource request
|
||
expected_to_launch = {"type_1": 3, "type_2": 1}
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert sorted(to_launch) == sorted(expected_to_launch)
|
||
assert len(reply.infeasible_gang_resource_requests) == 1
|
||
assert len(reply.infeasible_resource_requests) == 0
|
||
|
||
|
||
def test_bin_pack():
|
||
def bin_pack_residual(
|
||
node_resources: Dict[NodeType, Dict],
|
||
resource_requests: List[Dict],
|
||
anti_affinity: bool = False,
|
||
) -> List[Dict]:
|
||
node_type_configs = {}
|
||
for type_name, node_resource_dict in node_resources.items():
|
||
node_type_configs[type_name] = NodeTypeConfig(
|
||
name=type_name,
|
||
resources=node_resource_dict,
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1, # we only care about bin packing. Just allow 1
|
||
)
|
||
|
||
reply = schedule(node_type_configs, {}, resource_requests, anti_affinity)
|
||
if anti_affinity:
|
||
infeasible = []
|
||
for r in reply.infeasible_gang_resource_requests:
|
||
infeasible.append(ResourceRequestUtil.to_resource_maps(r.requests))
|
||
return infeasible
|
||
else:
|
||
return ResourceRequestUtil.to_resource_maps(
|
||
reply.infeasible_resource_requests
|
||
)
|
||
|
||
assert bin_pack_residual({"type_1": {"CPU": 0}}, [{"GPU": 2}, {"GPU": 2}]) == [
|
||
{"GPU": 2},
|
||
{"GPU": 2},
|
||
]
|
||
assert bin_pack_residual({"type_1": {"GPU": 2}}, [{"GPU": 2}, {"GPU": 2}]) == [
|
||
{"GPU": 2}
|
||
]
|
||
assert bin_pack_residual({"type_1": {"GPU": 4}}, [{"GPU": 2}, {"GPU": 2}]) == []
|
||
|
||
assert (
|
||
bin_pack_residual(
|
||
{"type_1": {"GPU": 2}, "type_2": {"GPU": 2, "CPU": 2}},
|
||
[{"GPU": 2}, {"GPU": 2}],
|
||
)
|
||
== []
|
||
)
|
||
assert bin_pack_residual(
|
||
{"type_1": {"GPU": 2}, "type_2": {"CPU": 2}},
|
||
[{"GPU": 2}, {"GPU": 2}],
|
||
) == [{"GPU": 2}]
|
||
|
||
assert bin_pack_residual(
|
||
{"type_1": {"GPU": 2}, "type_2": {"CPU": 2}},
|
||
[{"GPU": 2}, {"GPU": 2}],
|
||
) == [{"GPU": 2}]
|
||
|
||
assert bin_pack_residual(
|
||
{"type_1": {"GPU": 3}},
|
||
[{"GPU": 1}, {"GPU": 1}],
|
||
anti_affinity=True,
|
||
) == [[{"GPU": 1.0}, {"GPU": 1.0}]]
|
||
|
||
assert (
|
||
bin_pack_residual(
|
||
{"type_1": {"GPU": 3}},
|
||
[{"GPU": 1}, {"GPU": 1}],
|
||
anti_affinity=False,
|
||
)
|
||
== []
|
||
)
|
||
|
||
implicit_resource = ray._raylet.IMPLICIT_RESOURCE_PREFIX + "a"
|
||
assert (
|
||
bin_pack_residual(
|
||
{"type_1": {"CPU": 1}},
|
||
[{implicit_resource: 0.5}, {implicit_resource: 0.5}],
|
||
)
|
||
== []
|
||
)
|
||
assert bin_pack_residual(
|
||
{"type_1": {"CPU": 1}},
|
||
[{implicit_resource: 1}, {implicit_resource: 0.5}],
|
||
) == [{implicit_resource: 0.5}]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"source",
|
||
[
|
||
ResourceRequestSource.PENDING_DEMAND,
|
||
ResourceRequestSource.CLUSTER_RESOURCE_CONSTRAINT,
|
||
],
|
||
ids=["demand", "cluster_resource_constraint"],
|
||
)
|
||
def test_node_schedule_score(source):
|
||
def try_schedule(node_resources: Dict, requests: List[Dict]) -> Tuple:
|
||
node_type_config = NodeTypeConfig(
|
||
name="type_1",
|
||
resources=node_resources,
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
)
|
||
node = SchedulingNode.from_node_config(
|
||
node_config=node_type_config,
|
||
status=SchedulingNodeStatus.SCHEDULABLE,
|
||
node_kind=NodeKind.WORKER,
|
||
)
|
||
requests = [ResourceRequestUtil.make(r) for r in requests]
|
||
infeasible, score = node.try_schedule(requests, source)
|
||
return ResourceRequestUtil.to_resource_maps(infeasible), score
|
||
|
||
assert try_schedule({"CPU": 1}, [{"CPU": 1}]) == ([], (0, True, 1, 1.0, 1.0))
|
||
|
||
assert try_schedule({"GPU": 4}, [{"GPU": 2}]) == ([], (0, True, 1, 0.5, 0.5))
|
||
assert try_schedule({"GPU": 4}, [{"GPU": 1}, {"GPU": 1}]) == (
|
||
[],
|
||
(0, True, 1, 0.5, 0.5),
|
||
)
|
||
assert try_schedule({"GPU": 2}, [{"GPU": 2}]) == ([], (0, True, 1, 2, 2))
|
||
assert try_schedule({"GPU": 2}, [{"GPU": 1}, {"GPU": 1}]) == (
|
||
[],
|
||
(0, True, 1, 2, 2),
|
||
)
|
||
assert try_schedule({"GPU": 1}, [{"GPU": 1, "CPU": 1}, {"GPU": 1}]) == (
|
||
[{"GPU": 1, "CPU": 1}],
|
||
(0, True, 1, 1, 1),
|
||
)
|
||
assert try_schedule({"GPU": 1, "CPU": 1}, [{"GPU": 1, "CPU": 1}, {"GPU": 1}]) == (
|
||
[{"GPU": 1}],
|
||
(0, True, 2, 1, 1),
|
||
)
|
||
assert try_schedule({"GPU": 2, "TPU": 1}, [{"GPU": 2}]) == ([], (0, True, 1, 0, 1))
|
||
assert try_schedule({"CPU": 64}, [{"CPU": 64}]) == ([], (0, True, 1, 64, 64))
|
||
assert try_schedule({"CPU": 64}, [{"CPU": 32}]) == ([], (0, True, 1, 8, 8))
|
||
assert try_schedule({"CPU": 64}, [{"CPU": 16}, {"CPU": 16}]) == (
|
||
[],
|
||
(0, True, 1, 8, 8),
|
||
)
|
||
|
||
# GPU Scores
|
||
assert try_schedule({"GPU": 1, "CPU": 1}, [{"CPU": 1}]) == (
|
||
[],
|
||
(0, False, 1, 0.0, 0.5),
|
||
)
|
||
assert try_schedule({"GPU": 1, "CPU": 1}, [{"CPU": 1, "GPU": 1}]) == (
|
||
[],
|
||
(0, True, 2, 1.0, 1.0),
|
||
)
|
||
assert try_schedule({"GPU": 1, "CPU": 1}, [{"GPU": 1}]) == (
|
||
[],
|
||
(0, True, 1, 0.0, 0.5),
|
||
)
|
||
|
||
# Zero resources
|
||
assert try_schedule({"CPU": 0, "custom": 1}, [{"custom": 1}]) == (
|
||
[],
|
||
(0, True, 1, 1, 1),
|
||
)
|
||
assert try_schedule({"CPU": 0, "custom": 1}, [{"CPU": 1}]) == (
|
||
[{"CPU": 1}],
|
||
(0, True, 0, 0.0, 0.0),
|
||
)
|
||
|
||
# Implicit resources
|
||
implicit_resource = ray._raylet.IMPLICIT_RESOURCE_PREFIX + "a"
|
||
assert try_schedule({"CPU": 1}, [{implicit_resource: 1}]) == (
|
||
[],
|
||
(0, True, 0, 0.0, 0.0),
|
||
)
|
||
assert try_schedule({"CPU": 1}, [{implicit_resource: 1}] * 2) == (
|
||
[{implicit_resource: 1}],
|
||
(0, True, 0, 0.0, 0.0),
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"source",
|
||
[
|
||
ResourceRequestSource.PENDING_DEMAND,
|
||
ResourceRequestSource.CLUSTER_RESOURCE_CONSTRAINT,
|
||
],
|
||
ids=["demand", "cluster_resource_constraint"],
|
||
)
|
||
def test_node_schedule_label_selector_score(source):
|
||
def try_schedule_ls(
|
||
node_resources: Dict,
|
||
node_labels: Dict[str, str],
|
||
selectors,
|
||
) -> Tuple:
|
||
cfg = NodeTypeConfig(
|
||
name="type_1",
|
||
resources=node_resources,
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
labels=node_labels,
|
||
)
|
||
node = SchedulingNode.from_node_config(
|
||
node_config=cfg,
|
||
status=SchedulingNodeStatus.SCHEDULABLE,
|
||
node_kind=NodeKind.WORKER,
|
||
)
|
||
req = ResourceRequestUtil.make({"CPU": 1}, label_selectors=selectors)
|
||
infeasible, score = node.try_schedule([req], source)
|
||
return ResourceRequestUtil.to_resource_maps(infeasible), score
|
||
|
||
labels = {"ray.io/accelerator-type": "A100"}
|
||
|
||
# 1) A matching label selector should be schedulable on node type_1
|
||
label_selector_1 = [
|
||
[
|
||
(
|
||
"ray.io/accelerator-type",
|
||
LabelSelectorOperator.LABEL_OPERATOR_IN,
|
||
["TPU-v6e"],
|
||
)
|
||
],
|
||
[
|
||
(
|
||
"ray.io/accelerator-type",
|
||
LabelSelectorOperator.LABEL_OPERATOR_IN,
|
||
["B200"],
|
||
)
|
||
],
|
||
[
|
||
(
|
||
"ray.io/accelerator-type",
|
||
LabelSelectorOperator.LABEL_OPERATOR_IN,
|
||
["A100"],
|
||
)
|
||
],
|
||
]
|
||
assert try_schedule_ls({"CPU": 1}, labels, label_selector_1) == (
|
||
[],
|
||
(1, True, 1, 1.0, 1.0),
|
||
)
|
||
|
||
# 2) A non‑matching label selector should be infeasible
|
||
label_selector_2 = [
|
||
[("ray.io/accelerator-type", LabelSelectorOperator.LABEL_OPERATOR_IN, ["B200"])]
|
||
]
|
||
assert try_schedule_ls({"CPU": 1}, labels, label_selector_2) == (
|
||
[{"CPU": 1.0}],
|
||
(0, True, 0, 0.0, 0.0),
|
||
)
|
||
|
||
|
||
def test_get_nodes_packing_heuristic():
|
||
node_type_configs = {
|
||
"m4.large": NodeTypeConfig(
|
||
name="m4.large",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"m4.4xlarge": NodeTypeConfig(
|
||
name="m4.4xlarge",
|
||
resources={"CPU": 16},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=8,
|
||
),
|
||
"m4.16xlarge": NodeTypeConfig(
|
||
name="m4.16xlarge",
|
||
resources={"CPU": 64},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=4,
|
||
),
|
||
"p2.xlarge": NodeTypeConfig(
|
||
name="p2.xlarge",
|
||
resources={"CPU": 16, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"p2.8xlarge": NodeTypeConfig(
|
||
name="p2.8xlarge",
|
||
resources={"CPU": 32, "GPU": 8},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=4,
|
||
),
|
||
}
|
||
|
||
def get_nodes_for(
|
||
resource_requests,
|
||
anti_affinity=False,
|
||
max_nodes: Optional[int] = None,
|
||
current_nodes: Optional[Dict] = None,
|
||
):
|
||
reply = schedule(
|
||
node_type_configs,
|
||
current_nodes or {},
|
||
resource_requests,
|
||
anti_affinity=anti_affinity,
|
||
max_nodes=max_nodes,
|
||
)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
return to_launch
|
||
|
||
assert get_nodes_for([{"GPU": 8}]) == {"p2.8xlarge": 1}
|
||
assert get_nodes_for([{"GPU": 1}] * 6) == {"p2.8xlarge": 1}
|
||
assert get_nodes_for([{"GPU": 1}] * 4) == {"p2.xlarge": 4}
|
||
assert get_nodes_for([{"CPU": 32, "GPU": 1}] * 3) == {"p2.8xlarge": 3}
|
||
assert get_nodes_for([{"CPU": 64, "GPU": 1}] * 3) == {}
|
||
assert get_nodes_for([{"CPU": 64}] * 3) == {"m4.16xlarge": 3}
|
||
assert get_nodes_for([{"CPU": 64}, {"CPU": 1}]) == {
|
||
"m4.16xlarge": 1,
|
||
"m4.large": 1,
|
||
}
|
||
assert get_nodes_for([{"CPU": 64}, {"CPU": 9}, {"CPU": 9}]) == {
|
||
"m4.16xlarge": 1,
|
||
"m4.4xlarge": 2,
|
||
}
|
||
|
||
assert get_nodes_for([{"CPU": 16}] * 5) == {
|
||
"m4.16xlarge": 1,
|
||
"m4.4xlarge": 1,
|
||
}
|
||
assert get_nodes_for([{"CPU": 8}] * 10) == {
|
||
"m4.16xlarge": 1,
|
||
"m4.4xlarge": 1,
|
||
}
|
||
assert get_nodes_for([{"CPU": 1}] * 100) == {
|
||
"m4.16xlarge": 1,
|
||
"m4.4xlarge": 2,
|
||
"m4.large": 2,
|
||
}
|
||
|
||
assert get_nodes_for([{"GPU": 1}] + ([{"CPU": 1}] * 64)) == {
|
||
"m4.16xlarge": 1,
|
||
"p2.xlarge": 1,
|
||
}
|
||
assert get_nodes_for(([{"GPU": 1}] * 8) + ([{"CPU": 1}] * 64)) == {
|
||
"m4.4xlarge": 2,
|
||
"p2.8xlarge": 1,
|
||
}
|
||
assert get_nodes_for([{"GPU": 1}] * 8, anti_affinity=False) == {"p2.8xlarge": 1}
|
||
assert get_nodes_for([{"GPU": 1}] * 8, anti_affinity=True) == {"p2.xlarge": 8}
|
||
|
||
# GPU/CPU scheduling
|
||
node_type_configs = {
|
||
"cpu": NodeTypeConfig(
|
||
name="cpu",
|
||
resources={"CPU": 16},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"gpu": NodeTypeConfig(
|
||
name="gpu",
|
||
resources={"CPU": 16, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
assert get_nodes_for([{"CPU": 16}]) == {"cpu": 1}
|
||
assert get_nodes_for([{"CPU": 1}] * 30 + [{"GPU": 1, "CPU": 1}]) == {
|
||
"cpu": 1,
|
||
"gpu": 1,
|
||
}
|
||
assert get_nodes_for([{"GPU": 1, "CPU": 1}] + [{"CPU": 1}] * 30) == {
|
||
"cpu": 1,
|
||
"gpu": 1,
|
||
}
|
||
assert get_nodes_for([{"GPU": 1, "CPU": 1}] + [{"CPU": 1}] * 15) == {
|
||
"gpu": 1,
|
||
}
|
||
|
||
# GPU should be avoided
|
||
node_type_configs = {
|
||
"cpu": NodeTypeConfig(
|
||
name="cpu",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"gpu": NodeTypeConfig(
|
||
name="gpu",
|
||
resources={"CPU": 100, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
assert get_nodes_for([{"CPU": 1}] * 100, max_nodes=10) == {"cpu": 10}
|
||
# max_to_add eleven nodes allowed. First ten chosen to be "cpu",
|
||
# last chosen to be "gpu" due max_workers constraint on "cpu".
|
||
assert get_nodes_for([{"CPU": 1}] * 100, max_nodes=11) == {"cpu": 10, "gpu": 1}
|
||
assert get_nodes_for([{"CPU": 1}] * 100 + [{"GPU": 1}], max_nodes=100) == {"gpu": 1}
|
||
assert get_nodes_for([{"GPU": 1}] * 4 + [{"CPU": 1}] * 404, max_nodes=100) == {
|
||
"gpu": 4,
|
||
"cpu": 4,
|
||
}
|
||
|
||
# Max limit should be respected
|
||
node_type_configs = {
|
||
"m4.large": NodeTypeConfig(
|
||
name="m4.large",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
assert get_nodes_for([{"CPU": 1}] * 10, max_nodes=2) == {"m4.large": 2}
|
||
assert (
|
||
get_nodes_for([{"CPU": 1}] * 10, max_nodes=10, current_nodes={"m4.large": 10})
|
||
== {}
|
||
)
|
||
assert get_nodes_for([{"CPU": 1}] * 10, max_nodes=10) == {"m4.large": 5}
|
||
assert get_nodes_for([{"CPU": 1}] * 40) == {"m4.large": 10}
|
||
|
||
# Min workers should be respected
|
||
node_type_configs = {
|
||
"m2.large": NodeTypeConfig(
|
||
name="m2.large",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=5,
|
||
max_worker_nodes=10,
|
||
),
|
||
"m4.large": NodeTypeConfig(
|
||
name="m4.large",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"gpu": NodeTypeConfig(
|
||
name="gpu",
|
||
resources={"GPU": 2},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=2,
|
||
),
|
||
"gpubla": NodeTypeConfig(
|
||
name="gpubla", resources={"GPU": 1}, min_worker_nodes=0, max_worker_nodes=0
|
||
),
|
||
}
|
||
|
||
assert get_nodes_for([{"CPU": 2}] * 5) == {"m2.large": 5, "m4.large": 5, "gpu": 2}
|
||
assert get_nodes_for(
|
||
[{"CPU": 2}] * 5, current_nodes={"m2.large": 1, "m4.large": 1}
|
||
) == {"m2.large": 4, "m4.large": 4, "gpu": 2}
|
||
assert get_nodes_for([{"GPU": 1}] * 5) == {"m2.large": 5, "gpu": 2}
|
||
|
||
|
||
def test_min_workers_and_others():
|
||
node_type_configs = {
|
||
"p2.8xlarge": NodeTypeConfig(
|
||
name="p2.8xlarge",
|
||
resources={"CPU": 32, "GPU": 8},
|
||
min_worker_nodes=2,
|
||
max_worker_nodes=4,
|
||
),
|
||
"p2.xlarge": NodeTypeConfig(
|
||
name="p2.xlarge",
|
||
resources={"CPU": 16, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
def get_nodes_for(resource_requests, current_nodes=None, max_nodes=None):
|
||
reply = schedule(
|
||
node_type_configs,
|
||
current_nodes or {},
|
||
resource_requests,
|
||
max_nodes=max_nodes,
|
||
)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
infeasible = ResourceRequestUtil.to_resource_maps(
|
||
reply.infeasible_resource_requests
|
||
)
|
||
return to_launch, infeasible
|
||
|
||
assert get_nodes_for([{"GPU": 8}]) == ({"p2.8xlarge": 2}, [])
|
||
assert get_nodes_for([{"GPU": 8}] * 2) == ({"p2.8xlarge": 2}, [])
|
||
assert get_nodes_for([{"GPU": 8}] * 4) == ({"p2.8xlarge": 4}, [])
|
||
assert get_nodes_for([{"GPU": 8}] * 8) == ({"p2.8xlarge": 4}, [{"GPU": 8}] * 4)
|
||
|
||
assert get_nodes_for(
|
||
[{"GPU": 8}] * 3 + [{"GPU": 1}], current_nodes={"p2.8xlarge": 1}
|
||
) == ({"p2.xlarge": 1, "p2.8xlarge": 2}, [])
|
||
assert get_nodes_for(
|
||
[{"GPU": 8}] * 3 + [{"GPU": 1}], current_nodes={"p2.8xlarge": 2}
|
||
) == ({"p2.xlarge": 1, "p2.8xlarge": 1}, [])
|
||
assert get_nodes_for(
|
||
[{"GPU": 8}] * 3 + [{"GPU": 1}], current_nodes={"p2.8xlarge": 3}
|
||
) == ({"p2.xlarge": 1}, [])
|
||
assert get_nodes_for(
|
||
[{"GPU": 8}] * 5 + [{"GPU": 1}], current_nodes={"p2.8xlarge": 3}
|
||
) == ({"p2.xlarge": 1, "p2.8xlarge": 1}, [{"GPU": 8}])
|
||
|
||
node_type_configs = {
|
||
"p2.xlarge": NodeTypeConfig(
|
||
name="p2.xlarge",
|
||
resources={"CPU": 16, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
assert get_nodes_for([{"GPU": 1}] * 4, max_nodes=1) == (
|
||
{"p2.xlarge": 1},
|
||
[{"GPU": 1}] * 3,
|
||
)
|
||
assert get_nodes_for([{"GPU": 1}] * 4, max_nodes=2) == (
|
||
{"p2.xlarge": 2},
|
||
[{"GPU": 1}] * 2,
|
||
)
|
||
assert get_nodes_for([{"GPU": 1}] * 4, max_nodes=4) == ({"p2.xlarge": 4}, [])
|
||
|
||
|
||
def test_gang_scheduling_complex():
|
||
node_type_configs = {
|
||
"m4.large": NodeTypeConfig(
|
||
name="m4.large",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"p2.8xlarge": NodeTypeConfig(
|
||
name="p2.8xlarge",
|
||
resources={"CPU": 32, "GPU": 8},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=4,
|
||
),
|
||
}
|
||
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
AFFINITY = ResourceRequestUtil.PlacementConstraintType.AFFINITY
|
||
|
||
def get_nodes_for(gang_resource_requests) -> Tuple[Dict, List[List[Dict]]]:
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
gang_requests = []
|
||
for resource_requests, placement_constraint in gang_resource_requests:
|
||
key = f"PG_{str(time.time())}"
|
||
gang_requests.append(
|
||
[
|
||
ResourceRequestUtil.make(
|
||
r,
|
||
[(placement_constraint, key, key)]
|
||
if placement_constraint
|
||
else [],
|
||
)
|
||
for r in resource_requests
|
||
]
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=gang_requests,
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
infeasible = []
|
||
for r in reply.infeasible_gang_resource_requests:
|
||
infeasible.append(ResourceRequestUtil.to_resource_maps(r.requests))
|
||
|
||
return to_launch, infeasible
|
||
|
||
# Test various constraints.
|
||
get_nodes_for([([{"CPU": 16}, {"CPU": 16}], ANTI_AFFINITY)]) == (
|
||
{"p2.8xlarge": 2},
|
||
[],
|
||
)
|
||
get_nodes_for([([{"CPU": 16}, {"CPU": 16}], None)]) == (
|
||
{"p2.8xlarge": 1},
|
||
[],
|
||
)
|
||
get_nodes_for([([{"CPU": 2}, {"CPU": 2}], AFFINITY)]) == (
|
||
{"p2.8xlarge": 1},
|
||
[],
|
||
)
|
||
get_nodes_for([([{"CPU": 2}, {"CPU": 2}], None)]) == (
|
||
{"m4.large": 2},
|
||
[],
|
||
)
|
||
get_nodes_for([([{"CPU": 32}, {"CPU": 32}], AFFINITY)]) == (
|
||
{},
|
||
[[{"CPU": 32}, {"CPU": 32}]],
|
||
)
|
||
|
||
# Test many anti-affinity
|
||
get_nodes_for(
|
||
[
|
||
([{"CPU": 4}, {"CPU": 4}], ANTI_AFFINITY),
|
||
([{"CPU": 4}, {"CPU": 4}], ANTI_AFFINITY),
|
||
([{"CPU": 4}, {"CPU": 4}], ANTI_AFFINITY),
|
||
([{"CPU": 4}, {"CPU": 4}], ANTI_AFFINITY),
|
||
]
|
||
) == ({"p2.8xlarge": 2}, [])
|
||
|
||
# Test multiple affinity
|
||
get_nodes_for(
|
||
[
|
||
([{"CPU": 16}, {"CPU": 16}], AFFINITY),
|
||
([{"GPU": 4}, {"GPU": 4}], AFFINITY),
|
||
]
|
||
) == ({"p2.8xlarge": 1}, [])
|
||
|
||
|
||
def test_schedule_node_with_matching_labels():
|
||
"""
|
||
Test that a node with matching labels is considered schedulable and used to satisfy a request
|
||
with a label_selector.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
node_type_configs = {
|
||
"labelled_node": NodeTypeConfig(
|
||
name="labelled_node",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
labels={"accelerator": "A100"},
|
||
),
|
||
}
|
||
|
||
# The existing instance has matching dynamic label.
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="labelled_node",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id=b"r-1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=b"r-1",
|
||
ray_node_type_name="labelled_node",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
labels={"accelerator": "A100"},
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id="c-1",
|
||
)
|
||
|
||
# No new nodes should be launched if the existing node satisfies the request.
|
||
resource_request = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
label_selectors=[
|
||
[("accelerator", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])]
|
||
],
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[resource_request],
|
||
instances=[instance],
|
||
)
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
|
||
|
||
def test_scale_up_node_to_satisfy_labels():
|
||
"""
|
||
Test that a resource request with a label selector scales up a new node with
|
||
labels to satisfy the constraint.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"tpu_node": NodeTypeConfig(
|
||
name="tpu_node",
|
||
resources={"CPU": 1},
|
||
labels={"accelerator": "TPU"},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"gpu_node": NodeTypeConfig(
|
||
name="gpu_node",
|
||
resources={"CPU": 1},
|
||
labels={"accelerator": "A100"},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Request: want a node with label "accelerator: A100"
|
||
resource_request = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
label_selectors=[
|
||
[("accelerator", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])]
|
||
],
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[resource_request],
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
|
||
assert to_launch == {"gpu_node": 1}
|
||
|
||
|
||
def test_label_selector_fallback_priority():
|
||
"""
|
||
Test that a resource request with multiple label selectors scales up
|
||
the expected node given its fallback priority (i.e. earlier selectors are
|
||
satisfied first).
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"tpu_node": NodeTypeConfig(
|
||
name="tpu_node",
|
||
resources={"CPU": 1},
|
||
labels={"accelerator-type": "TPU"},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"gpu_node": NodeTypeConfig(
|
||
name="gpu_node",
|
||
resources={"CPU": 1},
|
||
labels={"accelerator-type": "A100"},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# 1). TPU node is scaled up to satisfy first label selector.
|
||
req1 = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
label_selectors=[
|
||
[("accelerator-type", LabelSelectorOperator.LABEL_OPERATOR_IN, ["TPU"])],
|
||
[("accelerator-type", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])],
|
||
],
|
||
)
|
||
reply1 = scheduler.schedule(
|
||
sched_request(node_type_configs=node_type_configs, resource_requests=[req1])
|
||
)
|
||
to_launch1, _ = _launch_and_terminate(reply1)
|
||
assert to_launch1 == {"tpu_node": 1}
|
||
|
||
# 1). Label selector falls back to second priority and scales up A100 node.
|
||
req2 = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
label_selectors=[
|
||
# infeasible
|
||
[("accelerator-type", LabelSelectorOperator.LABEL_OPERATOR_IN, ["B200"])],
|
||
[("accelerator-type", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])],
|
||
],
|
||
)
|
||
reply2 = scheduler.schedule(
|
||
sched_request(node_type_configs=node_type_configs, resource_requests=[req2])
|
||
)
|
||
to_launch2, _ = _launch_and_terminate(reply2)
|
||
assert to_launch2 == {"gpu_node": 1}
|
||
|
||
|
||
def test_pg_with_bundle_infeasible_label_selectors():
|
||
"""
|
||
Test that placement group scheduling honors bundle_label_selectors.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
AFFINITY = ResourceRequestUtil.PlacementConstraintType.AFFINITY
|
||
|
||
node_type_configs = {
|
||
"gpu_node": NodeTypeConfig(
|
||
name="gpu_node",
|
||
resources={"CPU": 4, "GPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=5,
|
||
labels={"accelerator": "A100"},
|
||
),
|
||
"tpu_node": NodeTypeConfig(
|
||
name="tpu_node",
|
||
resources={"CPU": 4},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=5,
|
||
labels={"accelerator": "TPU"},
|
||
),
|
||
}
|
||
|
||
# Create ResourceRequests for a placement group where each bundle has different label selectors
|
||
gpu_request = ResourceRequestUtil.make(
|
||
{"CPU": 2, "GPU": 1},
|
||
constraints=[(AFFINITY, "pg-1", "")],
|
||
label_selectors=[
|
||
[("accelerator", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])]
|
||
],
|
||
)
|
||
tpu_request = ResourceRequestUtil.make(
|
||
{"CPU": 2},
|
||
constraints=[(AFFINITY, "pg-1", "")],
|
||
label_selectors=[
|
||
[("accelerator", LabelSelectorOperator.LABEL_OPERATOR_IN, ["TPU"])]
|
||
],
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[[gpu_request, tpu_request]],
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
|
||
assert sorted(to_launch) == sorted({"gpu_node": 1, "tpu_node": 1})
|
||
|
||
# Both bundles require A100, but no node has enough resources -> infeasible
|
||
infeasbile_gpu_request = ResourceRequestUtil.make(
|
||
{"CPU": 3, "GPU": 1},
|
||
constraints=[(AFFINITY, "pg-2", "")],
|
||
label_selectors=[
|
||
[("accelerator", LabelSelectorOperator.LABEL_OPERATOR_IN, ["A100"])]
|
||
],
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[[infeasbile_gpu_request, infeasbile_gpu_request]],
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
|
||
assert to_launch == {}
|
||
assert len(reply.infeasible_gang_resource_requests) == 1
|
||
|
||
|
||
def test_get_nodes_with_resource_availabilities():
|
||
node_type_configs = {
|
||
"type_gpu1": NodeTypeConfig(
|
||
name="type_gpu1",
|
||
resources={"CPU": 8, "GPU": 1, "gpu1": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"type_gpu2": NodeTypeConfig(
|
||
name="type_gpu2",
|
||
resources={"CPU": 8, "GPU": 1, "gpu2": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"type_gpu3": NodeTypeConfig(
|
||
name="type_gpu3",
|
||
resources={"CPU": 8, "GPU": 1, "gpu3": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
"type_gpu4": NodeTypeConfig(
|
||
name="type_gpu4",
|
||
resources={"CPU": 1, "GPU": 1, "gpu4": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
def get_nodes_for(
|
||
resource_requests,
|
||
anti_affinity=False,
|
||
max_nodes: Optional[int] = None,
|
||
current_nodes: Optional[Dict] = None,
|
||
cloud_resource_availabilities=None,
|
||
):
|
||
reply = schedule(
|
||
node_type_configs,
|
||
current_nodes or {},
|
||
resource_requests,
|
||
anti_affinity=anti_affinity,
|
||
max_nodes=max_nodes,
|
||
cloud_resource_availabilities=cloud_resource_availabilities,
|
||
)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
infeasible = ResourceRequestUtil.to_resource_maps(
|
||
reply.infeasible_resource_requests
|
||
)
|
||
return to_launch, infeasible
|
||
|
||
# Pick the node type with the highest availability score when utilization scores are equal.
|
||
assert get_nodes_for(
|
||
[{"CPU": 8, "GPU": 1}],
|
||
cloud_resource_availabilities={
|
||
"type_gpu1": 0.1,
|
||
"type_gpu2": 1,
|
||
"type_gpu3": 0.2,
|
||
},
|
||
) == ({"type_gpu2": 1}, [])
|
||
|
||
# The availability score is set to 1 by default.
|
||
assert get_nodes_for(
|
||
[{"CPU": 8, "GPU": 1}],
|
||
cloud_resource_availabilities={"type_gpu2": 0.1, "type_gpu3": 0.2},
|
||
) == ({"type_gpu1": 1}, [])
|
||
|
||
assert get_nodes_for(
|
||
[{"CPU": 8, "GPU": 1}] * 2,
|
||
cloud_resource_availabilities={
|
||
"type_gpu1": 0.1,
|
||
"type_gpu2": 0.1,
|
||
"type_gpu3": 1,
|
||
},
|
||
) == ({"type_gpu3": 2}, [])
|
||
|
||
# The utilization score is the first factor to be considered.
|
||
assert get_nodes_for([{"CPU": 1, "GPU": 1}], cloud_resource_availabilities={}) == (
|
||
{"type_gpu4": 1},
|
||
[],
|
||
)
|
||
|
||
# The utilization score is the first factor to be considered.
|
||
assert get_nodes_for(
|
||
[{"CPU": 1, "GPU": 1}],
|
||
cloud_resource_availabilities={
|
||
"type_gpu1": 0.1,
|
||
"type_gpu2": 0.1,
|
||
"type_gpu3": 1,
|
||
"type_gpu4": 0.1,
|
||
},
|
||
) == ({"type_gpu4": 1}, [])
|
||
|
||
|
||
def test_infeasible_shape_caching():
|
||
"""
|
||
Test that identical requests failing to schedule on a node are cached,
|
||
drastically reducing calls to _try_schedule_one to prevent O(N^2 * M) hangs.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 2},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1, # Cluster can fit max one node
|
||
),
|
||
}
|
||
|
||
# Start with 1 existing node that has 2 CPUs available.
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 2},
|
||
total_resources={"CPU": 2},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
]
|
||
|
||
# Submit 1,000 identical tasks that all request 2 CPUs.
|
||
# Every request after the initial one should be cached and fail early.
|
||
resource_requests = [ResourceRequestUtil.make({"CPU": 2}) for _ in range(1000)]
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=resource_requests,
|
||
instances=instances,
|
||
max_num_nodes=1,
|
||
)
|
||
|
||
# Validate _try_schedule_one is only called twice by the scheduler.
|
||
orig_try_schedule_one = SchedulingNode._try_schedule_one
|
||
with patch.object(
|
||
SchedulingNode,
|
||
"_try_schedule_one",
|
||
autospec=True,
|
||
side_effect=orig_try_schedule_one,
|
||
) as mock_try_schedule:
|
||
reply = scheduler.schedule(request)
|
||
|
||
# 1 task should be scheduled on the existing node. The other 999 fail.
|
||
assert len(reply.infeasible_resource_requests) == 999
|
||
|
||
# Call 1: Fits the first 2-CPU request (Node is now full).
|
||
# Call 2: Evaluates the second 2-CPU request, fails, and adds to infeasible_shapes.
|
||
# Calls 3-1000: Bypassed entirely by the cache.
|
||
assert mock_try_schedule.call_count == 2
|
||
|
||
|
||
def test_infeasible_shape_caching_with_label_mutation():
|
||
"""
|
||
Test that dynamically adding labels clears the unavailable_shapes cache
|
||
so interleaved valid requests aren't skipped.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
ANTI_AFFINITY = ResourceRequestUtil.PlacementConstraintType.ANTI_AFFINITY
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 4},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=1,
|
||
),
|
||
}
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 4},
|
||
total_resources={"CPU": 4},
|
||
node_id=b"r1",
|
||
labels={"required-label": "true"},
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="c-1",
|
||
),
|
||
]
|
||
|
||
# Req A: needs "pg: 1" which is missing on node, fails initially.
|
||
req_a = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
constraints=[(ANTI_AFFINITY, "dummy-anti", "1")],
|
||
label_selectors=[[("pg", LabelSelectorOperator.LABEL_OPERATOR_IN, ["1"])]],
|
||
)
|
||
|
||
# Req B: Needs "required-label: true", adds "pg=1" to the node upon scheduling.
|
||
req_b = ResourceRequestUtil.make(
|
||
{"CPU": 1},
|
||
constraints=[(ANTI_AFFINITY, "pg", "1")],
|
||
label_selectors=[
|
||
[("required-label", LabelSelectorOperator.LABEL_OPERATOR_IN, ["true"])]
|
||
],
|
||
)
|
||
|
||
# Manually group the requests to force an interleaved [A, B, A] ordering.
|
||
req_a_grouped = ResourceRequestUtil.group_by_count([req_a])[0]
|
||
req_b_grouped = ResourceRequestUtil.group_by_count([req_b])[0]
|
||
resource_requests = [req_a_grouped, req_b_grouped, req_a_grouped]
|
||
|
||
request = SchedulingRequest(
|
||
disable_launch_config_check=False,
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=resource_requests,
|
||
current_instances=instances,
|
||
max_num_nodes=1,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
# Expected Sequence of Events:
|
||
# 1. Req A1 evaluates -> fails (no pg=1 on node). Caches shape A.
|
||
# 2. Req B evaluates -> succeeds. Mutates node state to add pg=1. Clears cache.
|
||
# 3. Req A2 evaluates -> Cache miss. Succeeds because node now has pg=1.
|
||
|
||
# Only the first Request A should have been marked infeasible.
|
||
assert len(reply.infeasible_resource_requests) == 1
|
||
|
||
# The scheduled node should have launched 0 new nodes (everything fit on the existing node)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
|
||
|
||
def test_identical_node_state_caching():
|
||
"""
|
||
Test that the scheduler avoids redundant deepcopies and simulations
|
||
for nodes with identical states.
|
||
"""
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 4},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=100,
|
||
),
|
||
}
|
||
|
||
# Create 100 identical pending nodes
|
||
instances = []
|
||
for i in range(100):
|
||
instances.append(
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.REQUESTED,
|
||
instance_id=f"pending-{i}",
|
||
)
|
||
)
|
||
)
|
||
|
||
# Submit a single request that requires 1 CPU
|
||
resource_requests = [ResourceRequestUtil.make({"CPU": 1})]
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=resource_requests,
|
||
instances=instances,
|
||
max_num_nodes=100,
|
||
)
|
||
|
||
# Track how many times try_schedule is actually called on a node
|
||
orig_try_schedule = SchedulingNode.try_schedule
|
||
with patch.object(
|
||
SchedulingNode,
|
||
"try_schedule",
|
||
autospec=True,
|
||
side_effect=orig_try_schedule,
|
||
) as mock_try_schedule:
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
# The scheduler should evaluate exactly one of the 100 identical pending nodes
|
||
assert mock_try_schedule.call_count == 1
|
||
|
||
# It should successfully schedule the task without needing to launch any new nodes,
|
||
# because it used the first pending node.
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
assert len(reply.infeasible_resource_requests) == 0
|
||
|
||
|
||
def test_ippr_resize_to_maximum_capacity():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Existing running node
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
)
|
||
|
||
# IPPR limits/specs and provider suggestion to upsize CPU to 2
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_status = IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=1,
|
||
desired_memory=1 * 1024 * 1024 * 1024,
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})],
|
||
instances=[instance],
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses={"pod-1": ippr_status},
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
# Scheduler should issue one IPPR action with desired set to suggested values
|
||
assert len(reply.to_ippr) == 1
|
||
assert reply.to_ippr[0].cloud_instance_id == "pod-1"
|
||
assert reply.to_ippr[0].desired_cpu == 4.0
|
||
assert reply.to_ippr[0].desired_memory == 8 * 1024 * 1024 * 1024
|
||
assert reply.to_launch == []
|
||
|
||
|
||
def test_ippr_resize_scale_out_if_one_ippr_is_new():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Existing running node
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
)
|
||
|
||
# IPPR limits/specs and provider suggestion to upsize CPU to 2
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_status = IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=1,
|
||
desired_memory=1 * 1024 * 1024 * 1024,
|
||
k8s_resize_status="new", # error or timeout will be rollback with a new IPPR action
|
||
raylet_id="r1",
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})],
|
||
instances=[instance],
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses={"pod-1": ippr_status},
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
# Scheduler should issue a new IPPR for the rollback.
|
||
assert len(reply.to_ippr) == 1
|
||
assert reply.to_ippr[0].cloud_instance_id == "pod-1"
|
||
assert reply.to_ippr[0].desired_cpu == 1
|
||
assert reply.to_ippr[0].desired_memory == 1 * 1024 * 1024 * 1024
|
||
# Scheduler should scale out a new node
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {"type_1": 1}
|
||
|
||
|
||
def test_ippr_resize_scale_out_if_one_ippr_is_inprogress():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Existing running node
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
)
|
||
|
||
# IPPR limits/specs and provider suggestion to upsize CPU to 2
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_status = IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=2,
|
||
desired_memory=2 * 1024 * 1024 * 1024,
|
||
k8s_resize_status="inprogress",
|
||
raylet_id="r1",
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 4})],
|
||
instances=[instance],
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses={"pod-1": ippr_status},
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
# Scheduler should not issue new IPPR action because the IPPR is in progress
|
||
assert len(reply.to_ippr) == 0
|
||
# Scheduler should scale out a new node
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {"type_1": 1}
|
||
|
||
|
||
def test_ippr_in_progress_exposes_desired_capacity_avoids_launch():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Existing running node with an in-progress resize to CPU=4
|
||
instance = make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
)
|
||
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_status = IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=4,
|
||
desired_memory=8 * 1024 * 1024 * 1024,
|
||
resizing_at=int(time.time()),
|
||
k8s_resize_status="inprogress",
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})],
|
||
instances=[instance],
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses={"pod-1": ippr_status},
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
# The scheduler should fit the 2-CPU request on the existing node (using desired capacity)
|
||
assert reply.to_launch == []
|
||
assert reply.to_ippr == [] # already in progress, no new IPPR action
|
||
|
||
|
||
def test_ippr_does_not_resize_pending_node_without_ray_node_id():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# Existing pending node has no ray_node_id yet.
|
||
instance = make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.ALLOCATED,
|
||
instance_id="i-1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
)
|
||
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_status = IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=1,
|
||
desired_memory=1 * 1024 * 1024 * 1024,
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})],
|
||
instances=[instance],
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses={"pod-1": ippr_status},
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
# Pending nodes without a ray_node_id should not be selected for IPPR.
|
||
assert reply.to_ippr == []
|
||
# Scheduler should also not launch a new node since the pending node could fulfill the request after IPPR.
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
assert to_launch == {}
|
||
|
||
|
||
def test_ippr_capacity_of_unselected_candidates_not_modified():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
instances = [
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r1",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-1",
|
||
node_id="r1",
|
||
),
|
||
cloud_instance_id="pod-1",
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r2",
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-2",
|
||
node_id="r2",
|
||
),
|
||
cloud_instance_id="pod-2",
|
||
),
|
||
make_autoscaler_instance(
|
||
ray_node=NodeState(
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1},
|
||
total_resources={"CPU": 1},
|
||
node_id=b"r3",
|
||
idle_duration_ms=10_000,
|
||
),
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id="i-3",
|
||
node_id="r3",
|
||
),
|
||
cloud_instance_id="pod-3",
|
||
),
|
||
]
|
||
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=8 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
ippr_statuses = {
|
||
"pod-1": IPPRStatus(
|
||
cloud_instance_id="pod-1",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=1,
|
||
desired_memory=1 * 1024 * 1024 * 1024,
|
||
),
|
||
"pod-2": IPPRStatus(
|
||
cloud_instance_id="pod-2",
|
||
spec=ippr_specs.groups["type_1"],
|
||
current_cpu=1,
|
||
current_memory=1 * 1024 * 1024 * 1024,
|
||
desired_cpu=1,
|
||
desired_memory=1 * 1024 * 1024 * 1024,
|
||
),
|
||
}
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
gang_resource_requests=[[ResourceRequestUtil.make({"CPU": 2})]],
|
||
instances=instances,
|
||
idle_timeout_s=0,
|
||
ippr_specs=ippr_specs,
|
||
ippr_statuses=ippr_statuses,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
|
||
assert reply.to_launch == []
|
||
# Only one IPPR candidate is selected for this gang request.
|
||
assert len(reply.to_ippr) == 1
|
||
assert {status.cloud_instance_id for status in reply.to_ippr} == {"pod-1"}
|
||
assert {status.desired_cpu for status in reply.to_ippr} == {4.0}
|
||
_, to_terminate = _launch_and_terminate(reply)
|
||
assert [instance_id for instance_id, _, _ in to_terminate] == ["i-3"]
|
||
# if pod-2 is accidentally selected for IPPR (it should not be),
|
||
# the cluster resources should be bigger than 5.0
|
||
assert reply.cluster_resources["CPU"] == 5.0
|
||
|
||
|
||
def test_ippr_max_limits_affect_new_node_capacity():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
# No existing instances; IPPR max allows new nodes to expose larger capacity
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=4 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})] * 4,
|
||
ippr_specs=ippr_specs,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# With IPPR max=4, all four 1-CPU bundles should fit on a single launched node
|
||
assert to_launch == {"type_1": 1}
|
||
assert reply.to_ippr == []
|
||
|
||
|
||
def test_ippr_max_limits_affect_new_node_capacity_2():
|
||
scheduler = ResourceDemandScheduler(event_logger)
|
||
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 1},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=10,
|
||
),
|
||
}
|
||
|
||
ippr_specs = IPPRSpecs(
|
||
groups={
|
||
"type_1": IPPRGroupSpec(
|
||
min_cpu=1,
|
||
max_cpu=4,
|
||
min_memory=1 * 1024 * 1024 * 1024,
|
||
max_memory=4 * 1024 * 1024 * 1024,
|
||
resize_timeout=60,
|
||
)
|
||
}
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 1})] * 6,
|
||
ippr_specs=ippr_specs,
|
||
)
|
||
|
||
reply = scheduler.schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
|
||
# Each launched node should be evaluated with IPPR max=4 CPU capacity:
|
||
# six 1-CPU bundles should require exactly two new nodes, not three.
|
||
assert to_launch == {"type_1": 2}
|
||
assert reply.to_ippr == []
|
||
|
||
|
||
class TestSchedulerPerformanceOptimizations:
|
||
"""Tests for large-cluster performance optimizations."""
|
||
|
||
def test_quick_reject_skips_exhausted_nodes(self):
|
||
"""Nodes with no available resources should be skipped without deepcopy."""
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 10, "memory": 100},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=100,
|
||
),
|
||
}
|
||
# Create instances where all resources are allocated (available = 0).
|
||
instances = []
|
||
for i in range(50):
|
||
instances.append(
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id=f"type_1-{i}",
|
||
node_id=f"r{i}type_1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=f"r{i}type_1".encode("utf-8"),
|
||
ray_node_type_name="type_1",
|
||
available_resources={}, # All resources used up
|
||
total_resources={"CPU": 10, "memory": 100},
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id=f"c-type_1-{i}",
|
||
)
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})] * 10,
|
||
instances=instances,
|
||
)
|
||
reply = ResourceDemandScheduler(event_logger).schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# Should launch new nodes since existing are exhausted.
|
||
assert to_launch == {"type_1": 2}
|
||
|
||
def test_quick_reject_partial_resources(self):
|
||
"""Nodes with some resources but below minimum demand are skipped."""
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 10, "memory": 100},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=100,
|
||
),
|
||
}
|
||
# Node has 1 CPU available but all requests need 4 CPU.
|
||
instances = []
|
||
for i in range(10):
|
||
instances.append(
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id=f"type_1-{i}",
|
||
node_id=f"r{i}type_1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=f"r{i}type_1".encode("utf-8"),
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 1, "memory": 10},
|
||
total_resources={"CPU": 10, "memory": 100},
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id=f"c-type_1-{i}",
|
||
)
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 4})] * 5,
|
||
instances=instances,
|
||
)
|
||
reply = ResourceDemandScheduler(event_logger).schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# All existing nodes have < 4 CPU available, must launch new.
|
||
# 5 requests × 4 CPU each, new nodes have 10 CPU → fits 2 per node → need 3.
|
||
assert to_launch == {"type_1": 3}
|
||
|
||
def test_quick_reject_does_not_skip_feasible_nodes(self):
|
||
"""Nodes with sufficient resources should still be scheduled on."""
|
||
node_type_configs = {
|
||
"type_1": NodeTypeConfig(
|
||
name="type_1",
|
||
resources={"CPU": 10, "memory": 100},
|
||
min_worker_nodes=0,
|
||
max_worker_nodes=100,
|
||
),
|
||
}
|
||
# Nodes have plenty of resources.
|
||
instances = []
|
||
for i in range(5):
|
||
instances.append(
|
||
make_autoscaler_instance(
|
||
im_instance=Instance(
|
||
instance_type="type_1",
|
||
status=Instance.RAY_RUNNING,
|
||
instance_id=f"type_1-{i}",
|
||
node_id=f"r{i}type_1",
|
||
),
|
||
ray_node=NodeState(
|
||
node_id=f"r{i}type_1".encode("utf-8"),
|
||
ray_node_type_name="type_1",
|
||
available_resources={"CPU": 10, "memory": 100},
|
||
total_resources={"CPU": 10, "memory": 100},
|
||
idle_duration_ms=0,
|
||
status=NodeStatus.RUNNING,
|
||
),
|
||
cloud_instance_id=f"c-type_1-{i}",
|
||
)
|
||
)
|
||
|
||
request = sched_request(
|
||
node_type_configs=node_type_configs,
|
||
resource_requests=[ResourceRequestUtil.make({"CPU": 2})] * 10,
|
||
instances=instances,
|
||
)
|
||
reply = ResourceDemandScheduler(event_logger).schedule(request)
|
||
to_launch, _ = _launch_and_terminate(reply)
|
||
# Existing nodes can handle all requests (5 nodes × 10 CPU ÷ 2 CPU = 25 slots).
|
||
assert to_launch == {}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if os.environ.get("PARALLEL_CI"):
|
||
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
|
||
else:
|
||
sys.exit(pytest.main(["-sv", __file__]))
|