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

2180 lines
82 KiB
Python

import random
import sys
from collections import defaultdict
from typing import List
from unittest import mock
from unittest.mock import Mock
import pytest
import ray
from ray._raylet import NodeID
from ray.serve._private import default_impl
from ray.serve._private.common import (
GANG_PG_NAME_PREFIX,
CreatePlacementGroupRequest,
DeploymentID,
GangPlacementGroupRequest,
ReplicaID,
)
from ray.serve._private.config import ReplicaConfig
from ray.serve._private.constants import (
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY,
)
from ray.serve._private.deployment_scheduler import (
AvailableNodeResources,
DeploymentDownscaleRequest,
DeploymentSchedulingInfo,
ReplicaSchedulingRequest,
ReplicaSchedulingRequestStatus,
RequestedResources,
Resources,
SpreadDeploymentSchedulingPolicy,
)
from ray.serve._private.deployment_state import DeploymentStateManager
from ray.serve._private.test_utils import (
MockActorClass,
MockClusterNodeInfoCache,
MockPlacementGroup,
)
from ray.tests.conftest import * # noqa
from ray.util.scheduling_strategies import (
In,
NodeAffinitySchedulingStrategy,
NodeLabelSchedulingStrategy,
PlacementGroupSchedulingStrategy,
)
def dummy():
pass
def rconfig(**config_opts):
return ReplicaConfig.create(dummy, **config_opts)
def get_random_resources(n: int) -> List[dict]:
"""Gets n random resources."""
resources = {
"CPU": lambda: random.randint(0, 10),
"GPU": lambda: random.randint(0, 10),
"memory": lambda: random.randint(0, 10),
"custom_A": lambda: random.randint(0, 10),
}
res = list()
for _ in range(n):
resource_dict = dict()
for resource, callable in resources.items():
if random.randint(0, 1) == 0:
resource_dict[resource] = callable()
res.append(resource_dict)
return res
class TestResources:
def test_base_resources_cannot_be_instantiated(self):
with pytest.raises(TypeError, match="cannot be instantiated directly"):
Resources()
@pytest.mark.parametrize("resource_type", ["CPU", "GPU", "memory"])
def test_basic(self, resource_type: str):
# basic resources
a = AvailableNodeResources({resource_type: 1})
b = RequestedResources({resource_type: 0})
assert a.can_fit(b)
b = AvailableNodeResources({resource_type: 0})
a = RequestedResources({resource_type: 1})
assert not b.can_fit(a)
def test_neither_bigger(self):
a = AvailableNodeResources({"CPU": 1, "GPU": 0})
b = RequestedResources({"CPU": 0, "GPU": 1})
assert not a == b
assert not a.can_fit(b)
combos = [tuple(get_random_resources(20)[i : i + 2]) for i in range(0, 20, 2)]
@pytest.mark.parametrize("resource_A,resource_B", combos)
@pytest.mark.parametrize(
"resource_class", [AvailableNodeResources, RequestedResources]
)
def test_soft_resources_consistent_comparison(
self, resource_A, resource_B, resource_class
):
"""Resources should have consistent comparison. Either A==B, A<B, or A>B."""
assert (
resource_class(resource_A) == resource_class(resource_B)
or resource_class(resource_A) > resource_class(resource_B)
or resource_class(resource_A) < resource_class(resource_B)
)
@pytest.mark.parametrize(
"resource_class", [AvailableNodeResources, RequestedResources]
)
def test_compare_resources(self, resource_class):
# Prioritize GPU
a = resource_class({"GPU": 1, "CPU": 10, "memory": 10, "custom": 10})
b = resource_class({"GPU": 2, "CPU": 0, "memory": 0, "custom": 0})
assert b > a
# Then CPU
a = resource_class({"GPU": 1, "CPU": 1, "memory": 10, "custom": 10})
b = resource_class({"GPU": 1, "CPU": 2, "memory": 0, "custom": 0})
assert b > a
# Then memory
a = resource_class({"GPU": 1, "CPU": 1, "memory": 1, "custom": 10})
b = resource_class({"GPU": 1, "CPU": 1, "memory": 2, "custom": 0})
assert b > a
# Then custom resources
a = resource_class({"GPU": 1, "CPU": 1, "memory": 1, "custom": 1})
b = resource_class({"GPU": 1, "CPU": 1, "memory": 1, "custom": 2})
assert b > a
@pytest.mark.parametrize(
"resource_class", [AvailableNodeResources, RequestedResources]
)
def test_sort_resources(self, resource_class):
"""Prioritize GPUs, CPUs, memory, then custom resources when sorting."""
a = resource_class({"GPU": 0, "CPU": 4, "memory": 99, "A": 10})
b = resource_class({"GPU": 0, "CPU": 2, "memory": 100})
c = resource_class({"GPU": 1, "CPU": 1, "memory": 50})
d = resource_class({"GPU": 2, "CPU": 0, "memory": 0})
e = resource_class({"GPU": 3, "CPU": 8, "memory": 10000, "A": 6})
f = resource_class({"GPU": 3, "CPU": 8, "memory": 10000, "A": 2})
for _ in range(10):
resources = [a, b, c, d, e, f]
random.shuffle(resources)
resources.sort(reverse=True)
assert resources == [e, f, d, c, a, b]
def test_custom_resources(self):
a = AvailableNodeResources({"alice": 3})
b = AvailableNodeResources({"alice": 2})
assert b < a
assert a.can_fit(b)
assert a + b == AvailableNodeResources(**{"alice": 5})
a = AvailableNodeResources({"bob": 2})
b = AvailableNodeResources({"CPU": 4})
assert a + b == AvailableNodeResources(**{"CPU": 4, "bob": 2})
def test_implicit_resources(self):
r = AvailableNodeResources()
# Implicit resources
assert r.get(f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}random") == 1
# Everything else
assert r.get("CPU") == 0
assert r.get("GPU") == 0
assert r.get("memory") == 0
assert r.get("random_custom") == 0
# Arithmetric with implicit resources
implicit_resource_1 = f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}whatever"
implicit_resource_2 = f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}whatever2"
a = AvailableNodeResources()
b = RequestedResources({implicit_resource_1: 0.5})
c = RequestedResources({implicit_resource_2: 0.25})
assert a.get(implicit_resource_1) == 1
assert a.can_fit(b)
a -= b
assert a.get(implicit_resource_1) == 0.5
assert a.can_fit(b)
a -= b
assert a.get(implicit_resource_1) == 0
assert not a.can_fit(b)
for i in range(4):
assert a.can_fit(c)
a -= c
assert a.get(implicit_resource_1) == 0
assert a.get(implicit_resource_2) == 1 - 0.25 * (i + 1)
# Implicit resources exhausted
assert not a.can_fit(c)
def test_deployment_scheduling_info():
info = DeploymentSchedulingInfo(
deployment_id=DeploymentID("a", "b"),
scheduling_policy=SpreadDeploymentSchedulingPolicy,
actor_resources=RequestedResources({"CPU": 2, "GPU": 1}),
)
assert info.required_resources == RequestedResources({"CPU": 2, "GPU": 1})
assert not info.is_non_strict_pack_pg()
info = DeploymentSchedulingInfo(
deployment_id=DeploymentID("a", "b"),
scheduling_policy=SpreadDeploymentSchedulingPolicy,
actor_resources=RequestedResources({"CPU": 2, "GPU": 1}),
placement_group_bundles=[
RequestedResources({"CPU": 100}),
RequestedResources({"GPU": 100}),
],
placement_group_strategy="STRICT_PACK",
)
assert info.required_resources == RequestedResources({"CPU": 100, "GPU": 100})
assert not info.is_non_strict_pack_pg()
info = DeploymentSchedulingInfo(
deployment_id=DeploymentID("a", "b"),
scheduling_policy=SpreadDeploymentSchedulingPolicy,
actor_resources=RequestedResources({"CPU": 1}),
placement_group_bundles=[
# Bundle 0 hosts the actor's CPU plus the CPU+GPU for a child
# task/actor captured into the PG.
RequestedResources({"CPU": 2, "GPU": 1}),
RequestedResources({"CPU": 1, "GPU": 1}),
],
placement_group_strategy="PACK",
)
# Actor is pinned as a subset of bundle 0, so required_resources is
# bundle 0's full reservation, not just actor_resources.
assert info.required_resources == RequestedResources({"CPU": 2, "GPU": 1})
assert info.is_non_strict_pack_pg()
def test_deployment_scheduling_info_required_resources_no_mutation():
dep_id = DeploymentID("app", "name")
actor = RequestedResources({"CPU": 1})
info = DeploymentSchedulingInfo(
deployment_id=dep_id,
scheduling_policy=SpreadDeploymentSchedulingPolicy,
actor_resources=actor,
max_replicas_per_node=2,
)
implicit = (
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}" f"{dep_id.app_name}:{dep_id.name}"
)
assert info.required_resources == RequestedResources({"CPU": 1, implicit: 0.5})
assert actor == RequestedResources({"CPU": 1})
assert implicit not in actor
def test_max_replicas_per_node_zero_skips_implicit_resource():
"""Falsy max_replicas_per_node (e.g. 0) must not trigger 1.0 / 0."""
dep_id = DeploymentID("app", "name")
implicit = (
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}" f"{dep_id.app_name}:{dep_id.name}"
)
info = DeploymentSchedulingInfo(
deployment_id=dep_id,
scheduling_policy=SpreadDeploymentSchedulingPolicy,
actor_resources=RequestedResources({"CPU": 1}),
max_replicas_per_node=0,
)
assert info.required_resources == RequestedResources({"CPU": 1})
assert implicit not in info.required_resources
req = ReplicaSchedulingRequest(
replica_id=ReplicaID("r0", dep_id),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={"name": "r0"},
actor_init_args=(),
on_scheduled=lambda *args, **kwargs: None,
max_replicas_per_node=0,
)
assert req.requested_resources == RequestedResources({"CPU": 1})
assert implicit not in req.requested_resources
def test_get_available_resources_per_node():
d_id = DeploymentID("a", "b")
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(
"node1", {"GPU": 10, "CPU": 32, "memory": 1024, "customx": 1}
)
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id,
ReplicaConfig.create(
dummy,
ray_actor_options={
"num_gpus": 1,
"num_cpus": 3,
"resources": {"customx": 0.1},
},
max_replicas_per_node=4,
),
)
# Without updating cluster node info cache, when a replica is marked
# as launching, the resources it uses should decrease the scheduler's
# view of current available resources per node in the cluster
scheduler._on_replica_launching(
ReplicaID(unique_id="replica0", deployment_id=d_id), target_node_id="node1"
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 9,
"CPU": 29,
"memory": 1024,
"customx": 0.9,
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}b:a": 0.75,
}
)
# Similarly when a replica is marked as running, the resources it
# uses should decrease current available resources per node
scheduler.on_replica_running(
ReplicaID(unique_id="replica1", deployment_id=d_id), node_id="node1"
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 8,
"CPU": 26,
"memory": 1024,
"customx": 0.8,
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}b:a": 0.5,
}
)
# Get updated info from GCS that available MEMORY has dropped,
# the decreased memory should reflect in current available resources
# per node, while also keeping track of the CPU, GPU, custom resources
# used by launching and running replicas
cluster_node_info_cache.set_available_resources_per_node(
"node1", {"GPU": 10, "CPU": 32, "memory": 256, "customx": 1}
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 8,
"CPU": 26,
"memory": 256,
"customx": 0.8,
f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}b:a": 0.5,
}
)
def test_get_node_to_running_replicas():
"""Test DeploymentScheduler._get_node_to_running_replicas()."""
d_id = DeploymentID("a", "b")
scheduler = default_impl.create_deployment_scheduler(
MockClusterNodeInfoCache(),
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(d_id, rconfig())
# Test simple fixed case
scheduler.on_replica_running(ReplicaID("r1", d_id), "node1")
scheduler.on_replica_running(ReplicaID("r2", d_id), "node1")
scheduler.on_replica_running(ReplicaID("r3", d_id), "node2")
assert scheduler._get_node_to_running_replicas() == {
"node1": {ReplicaID("r1", d_id), ReplicaID("r2", d_id)},
"node2": {ReplicaID("r3", d_id)},
}
scheduler.on_replica_stopping(ReplicaID("r1", d_id))
scheduler.on_replica_stopping(ReplicaID("r2", d_id))
scheduler.on_replica_stopping(ReplicaID("r3", d_id))
# Test random case
node_to_running_replicas = defaultdict(set)
for i in range(40):
node_id = f"node{random.randint(0,5)}"
r_id = ReplicaID(f"r{i}", d_id)
node_to_running_replicas[node_id].add(r_id)
scheduler.on_replica_running(r_id, node_id)
assert scheduler._get_node_to_running_replicas() == node_to_running_replicas
def test_get_available_resources_per_node_pg():
"""Test DeploymentScheduler._get_available_resources_per_node()."""
d_id = DeploymentID("a", "b")
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(
"node1", {"GPU": 10, "CPU": 32, "memory": 1024, "customx": 1}
)
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id,
ReplicaConfig.create(
dummy,
ray_actor_options={"num_cpus": 0},
placement_group_bundles=[{"GPU": 1}, {"CPU": 3}, {"customx": 0.1}],
placement_group_strategy="STRICT_PACK",
),
)
# Without updating cluster node info cache, when a replica is marked
# as launching, the resources it uses should decrease the scheduler's
# view of current available resources per node in the cluster
scheduler._on_replica_launching(
ReplicaID(unique_id="replica0", deployment_id=d_id), target_node_id="node1"
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 9,
"CPU": 29,
"memory": 1024,
"customx": 0.9,
}
)
# Similarly when a replica is marked as running, the resources it
# uses should decrease current available resources per node
scheduler.on_replica_running(
ReplicaID(unique_id="replica1", deployment_id=d_id), node_id="node1"
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 8,
"CPU": 26,
"memory": 1024,
"customx": 0.8,
}
)
# Get updated info from GCS that available MEMORY has dropped,
# the decreased memory should reflect in current available resources
# per node, while also keeping track of the CPU, GPU, custom resources
# used by launching and running replicas
cluster_node_info_cache.set_available_resources_per_node(
"node1", {"GPU": 10, "CPU": 32, "memory": 256, "customx": 1}
)
assert scheduler._get_available_resources_per_node().get(
"node1"
) == AvailableNodeResources(
**{
"GPU": 8,
"CPU": 26,
"memory": 256,
"customx": 0.8,
}
)
def test_best_fit_node():
"""Test DeploymentScheduler._best_fit_node()."""
scheduler = default_impl.create_deployment_scheduler(
MockClusterNodeInfoCache(),
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
# None of the nodes can schedule the replica
assert (
scheduler._best_fit_node(
required_resources=RequestedResources(GPU=1, CPU=1, customx=0.1),
available_resources={
"node1": AvailableNodeResources(GPU=3, CPU=3),
"node2": AvailableNodeResources(CPU=3, customx=1),
},
)
is None
)
# Only node2 can fit the replica
assert "node2" == scheduler._best_fit_node(
required_resources=RequestedResources(GPU=1, CPU=1, customx=0.1),
available_resources={
"node1": AvailableNodeResources(CPU=3),
"node2": AvailableNodeResources(GPU=1, CPU=3, customx=1),
"node3": AvailableNodeResources(CPU=3, customx=1),
},
)
# We should prioritize minimizing fragementation of GPUs over CPUs
assert "node1" == scheduler._best_fit_node(
required_resources=RequestedResources(GPU=1, CPU=1, customx=0.1),
available_resources={
"node1": AvailableNodeResources(GPU=2, CPU=10, customx=1),
"node2": AvailableNodeResources(GPU=10, CPU=2, customx=1),
},
)
# When GPU is the same, should prioritize minimizing fragmentation
# of CPUs over customer resources
assert "node2" == scheduler._best_fit_node(
required_resources=RequestedResources(GPU=1, CPU=1, customx=0.1),
available_resources={
"node1": AvailableNodeResources(GPU=10, CPU=5, customx=0.1),
"node2": AvailableNodeResources(GPU=10, CPU=2, customx=10),
},
)
# Custom resource prioritization: customx is more important than customy
with mock.patch(
"ray.serve._private.deployment_scheduler.RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES",
"customx,customy",
):
original = Resources.CUSTOM_PRIORITY
Resources.CUSTOM_PRIORITY = ["customx", "customy"]
assert "node2" == scheduler._best_fit_node(
required_resources=RequestedResources(customx=1, customy=1),
available_resources={
"node1": AvailableNodeResources(customx=2, customy=5),
"node2": AvailableNodeResources(customx=2, customy=1),
},
)
# If customx and customy are equal, GPU should determine best fit
assert "node2" == scheduler._best_fit_node(
required_resources=RequestedResources(customx=1, customy=1, GPU=1),
available_resources={
"node1": AvailableNodeResources(customx=2, customy=2, GPU=10),
"node2": AvailableNodeResources(customx=2, customy=2, GPU=2),
},
)
# restore
Resources.CUSTOM_PRIORITY = original
def test_schedule_replica():
"""Test DeploymentScheduler._schedule_replica()"""
d_id = DeploymentID("deployment1", "app1")
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=lambda request: MockPlacementGroup(request),
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(d_id, rconfig(ray_actor_options={"num_cpus": 1}))
scheduling_strategy = None
def set_scheduling_strategy(actor_handle, placement_group):
nonlocal scheduling_strategy
scheduling_strategy = actor_handle._options["scheduling_strategy"]
# Placement group without target node id
r0_id = ReplicaID(unique_id="r0", deployment_id=d_id)
scheduling_request = ReplicaSchedulingRequest(
replica_id=r0_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "r0"},
actor_init_args=(),
on_scheduled=set_scheduling_strategy,
)
scheduler._pending_replicas[d_id][r0_id] = scheduling_request
scheduler._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="some_default",
target_node_id=None,
target_labels={"abc": In("xyz")},
)
assert isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy)
assert len(scheduler._launching_replicas[d_id]) == 1
assert not scheduler._launching_replicas[d_id][r0_id].target_labels
assert not scheduler._launching_replicas[d_id][r0_id].target_node_id
# Placement group with target node id
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
scheduling_request = ReplicaSchedulingRequest(
replica_id=r1_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "r1"},
actor_init_args=(),
on_scheduled=set_scheduling_strategy,
)
scheduler._pending_replicas[d_id][r1_id] = scheduling_request
node_id_1 = NodeID.from_random().hex()
scheduler._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="some_default",
target_node_id=node_id_1,
target_labels={"abc": In("xyz")}, # this should get ignored
)
assert isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy)
assert len(scheduler._launching_replicas[d_id]) == 2
assert not scheduler._launching_replicas[d_id][r1_id].target_labels
assert scheduler._launching_replicas[d_id][r1_id].target_node_id == node_id_1
# Target node id without placement group
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
scheduling_request = ReplicaSchedulingRequest(
replica_id=r2_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={"name": "r2"},
actor_init_args=(),
on_scheduled=set_scheduling_strategy,
)
scheduler._pending_replicas[d_id][r2_id] = scheduling_request
scheduler._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="some_default",
target_node_id=node_id_1,
target_labels={"abc": In("xyz")}, # this should get ignored
)
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id_1
assert len(scheduler._launching_replicas[d_id]) == 3
assert not scheduler._launching_replicas[d_id][r2_id].target_labels
assert scheduler._launching_replicas[d_id][r2_id].target_node_id == node_id_1
# Target labels
r3_id = ReplicaID(unique_id="r3", deployment_id=d_id)
scheduling_request = ReplicaSchedulingRequest(
replica_id=r3_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={"name": "r3"},
actor_init_args=(),
on_scheduled=set_scheduling_strategy,
)
scheduler._pending_replicas[d_id][r3_id] = scheduling_request
scheduler._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="some_default",
target_node_id=None,
target_labels={"abc": In("xyz")},
)
assert isinstance(scheduling_strategy, NodeLabelSchedulingStrategy)
assert scheduling_strategy.soft
assert len(scheduler._launching_replicas[d_id]) == 4
assert not scheduler._launching_replicas[d_id][r3_id].target_node_id
assert len(scheduler._launching_replicas[d_id][r3_id].target_labels.keys()) == 1
operator = scheduler._launching_replicas[d_id][r3_id].target_labels["abc"]
assert isinstance(operator, In) and operator.values == ["xyz"]
# internal implicit resource with max_replicas_per_node
r4_id = ReplicaID(unique_id="r4", deployment_id=d_id)
scheduling_request = ReplicaSchedulingRequest(
replica_id=r4_id,
actor_def=MockActorClass(),
actor_resources={"my_rs": 1, "CPU": 1},
placement_group_bundles=None,
placement_group_strategy=None,
actor_options={"name": "r4", "num_cpus": 1, "resources": {"my_rs": 1}},
actor_init_args=(),
on_scheduled=set_scheduling_strategy,
max_replicas_per_node=10,
)
scheduler._pending_replicas[d_id][r4_id] = scheduling_request
scheduler._schedule_replica(
scheduling_request=scheduling_request,
default_scheduling_strategy="some_default",
target_node_id=None,
target_labels=None,
)
assert scheduling_strategy == "some_default"
assert len(scheduler._launching_replicas[d_id]) == 5
assert scheduling_request.actor_options == {
"name": "r4",
"num_cpus": 1,
"resources": {"my_rs": 1},
}
def test_downscale_multiple_deployments():
"""Test to make sure downscale prefers replicas without node id
and then replicas on a node with fewest replicas of all deployments.
"""
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
d1_id = DeploymentID(name="deployment1")
d2_id = DeploymentID(name="deployment2")
d1_r1_id = ReplicaID(
unique_id="replica1",
deployment_id=d1_id,
)
d1_r2_id = ReplicaID(
unique_id="replica2",
deployment_id=d1_id,
)
d1_r3_id = ReplicaID(
unique_id="replica3",
deployment_id=d1_id,
)
d2_r1_id = ReplicaID(
unique_id="replica1",
deployment_id=d2_id,
)
d2_r2_id = ReplicaID(
unique_id="replica2",
deployment_id=d2_id,
)
d2_r3_id = ReplicaID(
unique_id="replica3",
deployment_id=d2_id,
)
d2_r4_id = ReplicaID(
unique_id="replica4",
deployment_id=d2_id,
)
scheduler.on_deployment_created(d1_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d2_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_replica_running(d1_r1_id, "node1")
scheduler.on_replica_running(d1_r2_id, "node2")
scheduler.on_replica_running(d1_r3_id, "node2")
scheduler.on_replica_running(d2_r1_id, "node1")
scheduler.on_replica_running(d2_r2_id, "node2")
scheduler.on_replica_running(d2_r3_id, "node1")
scheduler.on_replica_running(d2_r4_id, "node1")
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
d1_id: DeploymentDownscaleRequest(deployment_id=d1_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
# Even though node1 has fewest replicas of deployment1
# but it has more replicas of all deployments so
# we should stop replicas from node2.
assert len(deployment_to_replicas_to_stop[d1_id]) == 1
assert deployment_to_replicas_to_stop[d1_id].issubset({d1_r2_id, d1_r3_id})
scheduler.on_replica_stopping(d1_r3_id)
scheduler.on_replica_stopping(d2_r3_id)
scheduler.on_replica_stopping(d2_r4_id)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
d1_id: DeploymentDownscaleRequest(deployment_id=d1_id, num_to_stop=1),
d2_id: DeploymentDownscaleRequest(deployment_id=d2_id, num_to_stop=1),
},
)
assert len(deployment_to_replicas_to_stop) == 2
# We should stop replicas from the same node.
assert len(deployment_to_replicas_to_stop[d1_id]) == 1
assert {r.unique_id for r in deployment_to_replicas_to_stop[d1_id]} == {
r.unique_id for r in deployment_to_replicas_to_stop[d2_id]
}
scheduler.on_replica_stopping(d1_r1_id)
scheduler.on_replica_stopping(d1_r2_id)
scheduler.on_replica_stopping(d2_r1_id)
scheduler.on_replica_stopping(d2_r2_id)
scheduler.on_deployment_deleted(d1_id)
scheduler.on_deployment_deleted(d2_id)
def test_downscale_head_node():
"""Test to make sure downscale deprioritizes replicas on the head node."""
head_node_id = "fake-head-node-id"
dep_id = DeploymentID(name="deployment1")
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override=head_node_id,
create_placement_group_fn_override=None,
)
r1_id = ReplicaID(
unique_id="replica1",
deployment_id=dep_id,
)
r2_id = ReplicaID(
unique_id="replica2",
deployment_id=dep_id,
)
r3_id = ReplicaID(
unique_id="replica3",
deployment_id=dep_id,
)
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_replica_running(r1_id, head_node_id)
scheduler.on_replica_running(r2_id, "node2")
scheduler.on_replica_running(r3_id, "node2")
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
assert deployment_to_replicas_to_stop[dep_id].issubset({r2_id, r3_id})
scheduler.on_replica_stopping(deployment_to_replicas_to_stop[dep_id].pop())
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
assert deployment_to_replicas_to_stop[dep_id] < {r2_id, r3_id}
scheduler.on_replica_stopping(deployment_to_replicas_to_stop[dep_id].pop())
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
assert deployment_to_replicas_to_stop[dep_id] == {r1_id}
scheduler.on_replica_stopping(r1_id)
scheduler.on_deployment_deleted(dep_id)
def test_downscale_single_deployment():
"""Test to make sure downscale prefers replicas without node id
and then replicas on a node with fewest replicas of all deployments.
"""
dep_id = DeploymentID(name="deployment1")
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node("node1")
cluster_node_info_cache.add_node("node2")
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
dep_id, ReplicaConfig.create(lambda x: x, ray_actor_options={"num_cpus": 0})
)
r1_id = ReplicaID(
unique_id="replica1",
deployment_id=dep_id,
)
r2_id = ReplicaID(
unique_id="replica2",
deployment_id=dep_id,
)
r3_id = ReplicaID(
unique_id="replica3",
deployment_id=dep_id,
)
r4_id = ReplicaID(
unique_id="replica4",
deployment_id=dep_id,
)
scheduler.on_replica_running(r1_id, "node1")
scheduler.on_replica_running(r2_id, "node1")
scheduler.on_replica_running(r3_id, "node2")
scheduler.on_replica_recovering(r4_id)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
# Prefer replica without node id
assert deployment_to_replicas_to_stop[dep_id] == {r4_id}
scheduler.on_replica_stopping(r4_id)
r5_id = ReplicaID(
unique_id="replica5",
deployment_id=dep_id,
)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={
dep_id: [
ReplicaSchedulingRequest(
replica_id=r5_id,
actor_def=Mock(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=lambda actor_handle, placement_group: actor_handle,
),
]
},
downscales={},
)
assert not deployment_to_replicas_to_stop
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
# Prefer replica without node id
assert deployment_to_replicas_to_stop[dep_id] == {r5_id}
scheduler.on_replica_stopping(r5_id)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=1)
},
)
assert len(deployment_to_replicas_to_stop) == 1
# Prefer replica on a node with fewest replicas of all deployments.
assert deployment_to_replicas_to_stop[dep_id] == {r3_id}
scheduler.on_replica_stopping(r3_id)
deployment_to_replicas_to_stop = scheduler.schedule(
upscales={},
downscales={
dep_id: DeploymentDownscaleRequest(deployment_id=dep_id, num_to_stop=2)
},
)
assert len(deployment_to_replicas_to_stop) == 1
assert deployment_to_replicas_to_stop[dep_id] <= {r1_id, r2_id}
scheduler.on_replica_stopping(r1_id)
scheduler.on_replica_stopping(r2_id)
scheduler.on_deployment_deleted(dep_id)
def test_schedule_passes_placement_group_options():
"""Test that bundle_label_selector is passed to CreatePlacementGroupRequest."""
cluster_node_info_cache = MockClusterNodeInfoCache()
captured_requests = []
def mock_create_pg(request):
captured_requests.append(request)
class MockPG:
def wait(self, *args):
return True
return MockPG()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=mock_create_pg,
)
dep_id = DeploymentID(name="pg_options_test")
# Use Spread policy here, but the logic is shared across policies.
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
test_labels = [{"region": "us-west"}]
# Create a request with the new options
req = ReplicaSchedulingRequest(
replica_id=ReplicaID("r1", dep_id),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={"name": "r1"},
actor_init_args=(),
on_scheduled=lambda *args, **kwargs: None,
placement_group_bundles=[{"CPU": 1}],
placement_group_bundle_label_selector=test_labels,
placement_group_strategy="STRICT_PACK",
)
scheduler.schedule(upscales={dep_id: [req]}, downscales={})
# Verify the PlacementGroupSchedulingRequest is created.
assert len(captured_requests) == 1
pg_request = captured_requests[0]
# bundle_label_selector should be passed to request.
assert pg_request.bundle_label_selector == test_labels
def test_schedule_pins_actor_to_bundle_0():
"""Replicas with a placement group are scheduled with placement_group_bundle_index=0."""
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=lambda request: MockPlacementGroup(request),
)
dep_id = DeploymentID(name="pin_test")
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
captured_handles = []
req = ReplicaSchedulingRequest(
replica_id=ReplicaID("r1", dep_id),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={"name": "r1"},
actor_init_args=(),
on_scheduled=lambda handle, **kwargs: captured_handles.append(handle),
placement_group_bundles=[{"CPU": 1, "GPU": 1}, {"CPU": 1, "GPU": 1}],
placement_group_strategy="PACK",
)
scheduler.schedule(upscales={dep_id: [req]}, downscales={})
assert len(captured_handles) == 1
strategy = captured_handles[0]._options["scheduling_strategy"]
assert isinstance(strategy, PlacementGroupSchedulingStrategy)
assert strategy.placement_group_bundle_index == 0
def test_filter_nodes_by_label_selector():
"""Test _filter_nodes_by_label_selector logic used by _find_best_fit_node_for_pack
when bin-packing, such that label constraints are enforced for the preferred node."""
class MockScheduler(default_impl.DefaultDeploymentScheduler):
def __init__(self):
pass
scheduler = MockScheduler()
nodes = {
"n1": AvailableNodeResources(),
"n2": AvailableNodeResources(),
"n3": AvailableNodeResources(),
}
node_labels = {
"n1": {"region": "us-west", "gpu": "T4", "env": "prod"},
"n2": {"region": "us-east", "gpu": "A100", "env": "dev"},
"n3": {"region": "me-central", "env": "staging"}, # No GPU label
}
# equals operator
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"region": "us-west"}, node_labels
)
assert set(filtered.keys()) == {"n1"}
# not equals operator
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"region": "!us-west"}, node_labels
)
assert set(filtered.keys()) == {"n2", "n3"}
# in operator
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"region": "in(us-west,us-east)"}, node_labels
)
assert set(filtered.keys()) == {"n1", "n2"}
# !in operator
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"env": "!in(dev,staging)"}, node_labels
)
assert set(filtered.keys()) == {"n1"}
# Missing labels treated as not a match for equality.
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"gpu": "A100"}, node_labels
)
assert set(filtered.keys()) == {"n2"}
# Not equal should match node with missing labels.
filtered = scheduler._filter_nodes_by_label_selector(
nodes, {"gpu": "!T4"}, node_labels
)
assert set(filtered.keys()) == {"n2", "n3"}
def test_build_pack_placement_candidates():
"""Test strategy generation logic in DefaultDeploymentScheduler._build_pack_placement_candidates,
verifying that the scheduler correctly generates a list of (resources, labels) tuples to
attempt for scheduling."""
# Setup scheduler with mocks
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="head_node",
create_placement_group_fn_override=None,
)
# Basic Ray Actor
req_basic = ReplicaSchedulingRequest(
replica_id=ReplicaID("r1", DeploymentID(name="d1")),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=Mock(),
)
strategies = scheduler._build_pack_placement_candidates(req_basic)
assert len(strategies) == 1
assert strategies[0][0] == {"CPU": 1}
assert strategies[0][1] == []
# Actor with label_selector and fallback_strategy
req_fallback = ReplicaSchedulingRequest(
replica_id=ReplicaID("r2", DeploymentID(name="d1")),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={
"label_selector": {"region": "us-west"},
"fallback_strategy": [{"label_selector": {"region": "us-east"}}],
},
actor_init_args=(),
on_scheduled=Mock(),
)
strategies = scheduler._build_pack_placement_candidates(req_fallback)
assert len(strategies) == 2
assert strategies[0][0] == {"CPU": 1}
assert strategies[0][1] == [{"region": "us-west"}]
assert strategies[1][0] == {"CPU": 1}
assert strategies[1][1] == [{"region": "us-east"}]
# Scheduling replica with placement group PACK strategy and bundle_label_selector
req_pack = ReplicaSchedulingRequest(
replica_id=ReplicaID("r4", DeploymentID(name="d1")),
actor_def=MockActorClass(),
actor_resources={"CPU": 0.1},
actor_options={},
actor_init_args=(),
on_scheduled=Mock(),
placement_group_bundles=[{"CPU": 5}],
placement_group_strategy="PACK",
placement_group_bundle_label_selector=[
{"accelerator-type": "H100"},
{"accelerator-type": "H100"},
],
)
with pytest.raises(NotImplementedError):
scheduler._build_pack_placement_candidates(req_pack)
# Scheduling replica with placement group STRICT_PACK strategy and bundle_label_selector
req_pg = ReplicaSchedulingRequest(
replica_id=ReplicaID("r3", DeploymentID(name="d1")),
actor_def=MockActorClass(),
actor_resources={},
actor_options={},
actor_init_args=(),
on_scheduled=Mock(),
placement_group_bundles=[{"CPU": 2}],
placement_group_strategy="STRICT_PACK",
placement_group_bundle_label_selector=[{"accelerator-type": "A100"}],
)
strategies = scheduler._build_pack_placement_candidates(req_pg)
assert len(strategies) == 1
assert strategies[0][0] == {"CPU": 2}
assert strategies[0][1] == [{"accelerator-type": "A100"}]
def test_build_pack_placement_candidates_pg_fallback_error():
"""
Test that providing placement_group_fallback_strategy raises NotImplementedError.
"""
cluster_node_info_cache = MockClusterNodeInfoCache()
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="head_node",
create_placement_group_fn_override=None,
)
# Create a request with placement_group_fallback_strategy defined.
req = ReplicaSchedulingRequest(
replica_id=ReplicaID("r1", DeploymentID(name="d1")),
actor_def=MockActorClass(),
actor_resources={},
actor_options={},
actor_init_args=(),
on_scheduled=Mock(),
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="STRICT_PACK",
# Raises NotImplementedError since not added to placement group options yet.
placement_group_fallback_strategy=[{"label_selector": {"zone": "us-east-1a"}}],
)
# Verify the scheduler raises the expected error
with pytest.raises(NotImplementedError, match="not yet supported"):
scheduler._build_pack_placement_candidates(req)
@pytest.mark.skipif(
not RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs pack strategy."
)
class TestPackScheduling:
def test_basic(self):
d_id1 = DeploymentID(name="deployment1")
d_id2 = DeploymentID(name="deployment2")
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(node_id_1, {"CPU": 3})
cluster_node_info_cache.add_node(node_id_2, {"CPU": 2})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d_id2, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(dummy, ray_actor_options={"num_cpus": 1}),
)
scheduler.on_deployment_deployed(
d_id2,
ReplicaConfig.create(dummy, ray_actor_options={"num_cpus": 3}),
)
on_scheduled_mock = Mock()
on_scheduled_mock2 = Mock()
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id=f"r{i}", deployment_id=d_id1),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
for i in range(2)
],
d_id2: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r2", deployment_id=d_id2),
actor_def=MockActorClass(),
actor_resources={"CPU": 3},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock2,
)
],
},
downscales={},
)
assert len(on_scheduled_mock.call_args_list) == 2
for call in on_scheduled_mock.call_args_list:
assert call.kwargs == {"placement_group": None}
assert len(call.args) == 1
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id_2
assert len(on_scheduled_mock2.call_args_list) == 1
call = on_scheduled_mock2.call_args_list[0]
assert call.kwargs == {"placement_group": None}
assert len(call.args) == 1
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id_1
def test_placement_groups(self):
d_id1 = DeploymentID(name="deployment1")
d_id2 = DeploymentID(name="deployment2")
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node("node1", {"CPU": 3})
cluster_node_info_cache.add_node("node2", {"CPU": 2})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=lambda *args, **kwargs: MockPlacementGroup( # noqa
*args, **kwargs
),
)
_ = ray.util.placement_group
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d_id2, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(
dummy,
ray_actor_options={"num_cpus": 0},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5}],
placement_group_strategy="STRICT_PACK",
),
)
scheduler.on_deployment_deployed(
d_id2,
ReplicaConfig.create(
dummy,
ray_actor_options={"num_cpus": 0},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 2.5}],
placement_group_strategy="STRICT_PACK",
),
)
on_scheduled_mock = Mock()
on_scheduled_mock2 = Mock()
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id=f"r{i}", deployment_id=d_id1),
actor_def=MockActorClass(),
actor_resources={"CPU": 0},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "random_replica"},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
for i in range(2)
],
d_id2: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r2", deployment_id=d_id2),
actor_def=MockActorClass(),
actor_resources={"CPU": 0},
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 2.5}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "some_replica"},
actor_init_args=(),
on_scheduled=on_scheduled_mock2,
)
],
},
downscales={},
)
assert len(on_scheduled_mock.call_args_list) == 2
for call in on_scheduled_mock.call_args_list:
assert len(call.args) == 1
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy)
assert call.kwargs.get("placement_group")._soft_target_node_id == "node2"
assert len(on_scheduled_mock2.call_args_list) == 1
call = on_scheduled_mock2.call_args_list[0]
assert len(call.args) == 1
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy)
assert call.kwargs.get("placement_group")._soft_target_node_id == "node1"
def test_heterogeneous_resources(self):
d_id1 = DeploymentID(name="deployment1")
d_id2 = DeploymentID(name="deployment2")
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(node_id_1, {"GPU": 4, "CPU": 6})
cluster_node_info_cache.add_node(node_id_2, {"GPU": 10, "CPU": 2})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d_id2, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(
dummy, ray_actor_options={"num_gpus": 2, "num_cpus": 2}
),
)
scheduler.on_deployment_deployed(
d_id2,
ReplicaConfig.create(
dummy, ray_actor_options={"num_gpus": 1, "num_cpus": 1}
),
)
on_scheduled_mock = Mock()
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r0", deployment_id=d_id1),
actor_def=MockActorClass(),
actor_resources={"GPU": 2, "CPU": 2},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
],
d_id2: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id=f"r{i+1}", deployment_id=d_id2),
actor_def=MockActorClass(),
actor_resources={"GPU": 1, "CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
for i in range(2)
],
},
downscales={},
)
# Even though scheduling on node 2 would minimize fragmentation
# of CPU resources, we should prioritize minimizing fragmentation
# of GPU resources first, so all 3 replicas should be scheduled
# to node 1
assert len(on_scheduled_mock.call_args_list) == 3
for call in on_scheduled_mock.call_args_list:
assert len(call.args) == 1
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id_1
assert call.kwargs == {"placement_group": None}
def test_max_replicas_per_node(self):
"""Test that at most `max_replicas_per_node` number of replicas
are scheduled onto a node even if that node has more resources.
"""
d_id1 = DeploymentID(name="deployment1")
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
# Should try to schedule on node1 to minimize fragmentation
cluster_node_info_cache.add_node(node_id_1, {"CPU": 20})
cluster_node_info_cache.add_node(node_id_2, {"CPU": 21})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=lambda *args, **kwargs: MockPlacementGroup( # noqa
*args, **kwargs
),
)
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(
dummy, max_replicas_per_node=4, ray_actor_options={"num_cpus": 2}
),
)
state = defaultdict(int)
def on_scheduled(actor_handle, placement_group):
scheduling_strategy = actor_handle._options["scheduling_strategy"]
if isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy):
state[scheduling_strategy.node_id] += 1
elif isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy):
state[placement_group._soft_target_node_id] += 1
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(
unique_id=f"replica{i}", deployment_id=d_id1
),
actor_def=MockActorClass(),
actor_resources={"CPU": 2},
max_replicas_per_node=4,
actor_options={"name": "random"},
actor_init_args=(),
on_scheduled=on_scheduled,
)
for i in range(5)
]
},
downscales={},
)
assert state[node_id_1] == 4
assert state[node_id_2] == 1
def test_heterogeneous_resources_with_max_replicas_per_node(self):
d_id1 = DeploymentID(name="deployment1")
d_id2 = DeploymentID(name="deployment2")
max_replicas_per_node = {d_id1: 2, d_id2: 3}
cluster_node_info_cache = MockClusterNodeInfoCache()
node1 = NodeID.from_random().hex()
node2 = NodeID.from_random().hex()
cluster_node_info_cache.add_node(node1, {"GPU": 8, "CPU": 32})
cluster_node_info_cache.add_node(node2, {"GPU": 10, "CPU": 32})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d_id2, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(
dummy,
max_replicas_per_node=max_replicas_per_node[d_id1],
ray_actor_options={"num_gpus": 2, "num_cpus": 1},
),
)
scheduler.on_deployment_deployed(
d_id2,
ReplicaConfig.create(
dummy,
max_replicas_per_node=max_replicas_per_node[d_id2],
ray_actor_options={"num_gpus": 2, "num_cpus": 1},
),
)
state = defaultdict(list)
def on_scheduled(actor_handle, *args, **kwargs):
scheduling_strategy = actor_handle._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
state[scheduling_strategy.node_id].append(
actor_handle._options["deployment"]
)
# Schedule one d1 and one d2
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="replica0", deployment_id=d_id1),
actor_def=MockActorClass(),
actor_resources={"GPU": 2},
max_replicas_per_node=max_replicas_per_node[d_id1],
actor_options={"name": "random", "deployment": d_id1},
actor_init_args=(),
on_scheduled=on_scheduled,
),
],
d_id2: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="replica1", deployment_id=d_id2),
actor_def=MockActorClass(),
actor_resources={"GPU": 2},
max_replicas_per_node=max_replicas_per_node[d_id2],
actor_options={"name": "random", "deployment": d_id2},
actor_init_args=(),
on_scheduled=on_scheduled,
)
],
},
downscales={},
)
assert state[node1].count(d_id1) == 1
assert state[node1].count(d_id2) == 1
assert len(state[node2]) == 0
# Schedule two more d1
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(
unique_id=f"replica{i+2}", deployment_id=d_id1
),
actor_def=MockActorClass(),
actor_resources={"GPU": 2},
max_replicas_per_node=max_replicas_per_node[d_id1],
actor_options={"name": "random", "deployment": d_id1},
actor_init_args=(),
on_scheduled=on_scheduled,
)
for i in range(2)
],
},
downscales={},
)
# 2 d1 + 1 d2 on node1
assert state[node1].count(d_id1) == 2
assert state[node1].count(d_id2) == 1
# 1 d1 on node2 because of max_replicas_per_node=2 (otherwise node1 could have fit both new d1 replicas)
assert state[node2].count(d_id1) == 1
assert state[node2].count(d_id2) == 0
def test_custom_resources(self):
d_id = DeploymentID(name="deployment1")
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(node_id_1, {"CPU": 3})
cluster_node_info_cache.add_node(node_id_2, {"CPU": 100, "customA": 1})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=lambda *args, **kwargs: MockPlacementGroup( # noqa
*args, **kwargs
),
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id,
ReplicaConfig.create(
dummy, ray_actor_options={"num_cpus": 2, "resources": {"customA": 0.1}}
),
)
# Despite trying to schedule on node that minimizes fragmentation,
# should respect custom resources and schedule onto node2
def on_scheduled(actor_handle, placement_group):
scheduling_strategy = actor_handle._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id_2
scheduler.schedule(
upscales={
d_id: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r0", deployment_id=d_id),
actor_def=MockActorClass(),
actor_resources={"CPU": 2, "customA": 0.1},
actor_options={"name": "random"},
actor_init_args=(),
on_scheduled=on_scheduled,
)
]
},
downscales={},
)
def test_actor_creation_failure_does_not_decrement_resources(self):
"""When actor creation fails for a replica, available resources
should not be decremented so subsequent replicas in the same
scheduling batch can still use that node.
"""
d_id = DeploymentID(name="deployment1")
node_id = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
# Node has exactly 1 CPU — enough for one 1-CPU replica.
cluster_node_info_cache.add_node(node_id, {"CPU": 1})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id,
ReplicaConfig.create(dummy, ray_actor_options={"num_cpus": 1}),
)
# Create a mock actor class whose .options().remote() raises on the
# first call (simulating actor creation failure) but succeeds after.
call_count = 0
class FailOnceMockActorClass(MockActorClass):
def remote(self, *args):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("Simulated actor creation failure")
return super().remote(*args)
on_scheduled_mock = Mock()
r0_id = ReplicaID(unique_id="r0", deployment_id=d_id)
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
req0 = ReplicaSchedulingRequest(
replica_id=r0_id,
actor_def=FailOnceMockActorClass(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
req1 = ReplicaSchedulingRequest(
replica_id=r1_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
scheduler.schedule(
upscales={d_id: [req0, req1]},
downscales={},
)
# The first replica should have failed.
assert req0.status == ReplicaSchedulingRequestStatus.ACTOR_CREATION_FAILED
# The second replica should have succeeded and been scheduled to the
# node.
assert req1.status == ReplicaSchedulingRequestStatus.SUCCEEDED
assert on_scheduled_mock.call_count == 1
call = on_scheduled_mock.call_args_list[0]
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, NodeAffinitySchedulingStrategy)
assert scheduling_strategy.node_id == node_id
def test_pg_creation_failure_does_not_decrement_resources(self):
"""When placement group creation fails for a replica, available
resources should not be decremented so subsequent replicas in the
same scheduling batch can still use that node.
"""
d_id = DeploymentID(name="deployment1")
node_id = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
# Node has exactly 1 CPU — enough for one replica with 1-CPU PG.
cluster_node_info_cache.add_node(node_id, {"CPU": 1})
call_count = 0
def fail_once_create_pg(request):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("Simulated PG creation failure")
return MockPlacementGroup(request)
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=fail_once_create_pg,
)
scheduler.on_deployment_created(d_id, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id,
ReplicaConfig.create(
dummy,
ray_actor_options={"num_cpus": 0},
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="STRICT_PACK",
),
)
on_scheduled_mock = Mock()
r0_id = ReplicaID(unique_id="r0", deployment_id=d_id)
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
req0 = ReplicaSchedulingRequest(
replica_id=r0_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 0},
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "r0"},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
req1 = ReplicaSchedulingRequest(
replica_id=r1_id,
actor_def=MockActorClass(),
actor_resources={"CPU": 0},
placement_group_bundles=[{"CPU": 1}],
placement_group_strategy="STRICT_PACK",
actor_options={"name": "r1"},
actor_init_args=(),
on_scheduled=on_scheduled_mock,
)
scheduler.schedule(
upscales={d_id: [req0, req1]},
downscales={},
)
# The first replica should have failed at PG creation.
assert (
req0.status
== ReplicaSchedulingRequestStatus.PLACEMENT_GROUP_CREATION_FAILED
)
# The second replica should still succeed.
assert req1.status == ReplicaSchedulingRequestStatus.SUCCEEDED
assert on_scheduled_mock.call_count == 1
call = on_scheduled_mock.call_args_list[0]
scheduling_strategy = call.args[0]._options["scheduling_strategy"]
assert isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy)
def test_pack_prefers_newly_non_idle_node(self):
"""After scheduling a replica to a previously idle node, subsequent
replicas in the same batch should prefer that node (now non-idle)
over other idle nodes, even if the idle node is a tighter fit.
Regression test: without updating node_to_running_replicas after
each scheduling, the PACK scheduler would treat all initially-idle
nodes as idle for the entire batch, falling through to pure
best-fit and potentially spreading replicas across nodes.
"""
d_id1 = DeploymentID(name="deployment1")
d_id2 = DeploymentID(name="deployment2")
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
cluster_node_info_cache = MockClusterNodeInfoCache()
# Node 1 has GPU + CPU; node 2 has only CPU.
# After the GPU replica is placed on node 1, node 2 would be
# a tighter best-fit for a CPU-only replica (2 CPU remaining
# vs 4 CPU on node 1). But PACK should prefer node 1 because
# it is now non-idle.
cluster_node_info_cache.add_node(node_id_1, {"GPU": 1, "CPU": 4})
cluster_node_info_cache.add_node(node_id_2, {"CPU": 2})
scheduler = default_impl.create_deployment_scheduler(
cluster_node_info_cache,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=None,
)
scheduler.on_deployment_created(d_id1, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_created(d_id2, SpreadDeploymentSchedulingPolicy())
scheduler.on_deployment_deployed(
d_id1,
ReplicaConfig.create(
dummy, ray_actor_options={"num_gpus": 1, "num_cpus": 0}
),
)
scheduler.on_deployment_deployed(
d_id2,
ReplicaConfig.create(dummy, ray_actor_options={"num_cpus": 1}),
)
on_scheduled_mock1 = Mock()
on_scheduled_mock2 = Mock()
scheduler.schedule(
upscales={
d_id1: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r0", deployment_id=d_id1),
actor_def=MockActorClass(),
actor_resources={"GPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock1,
)
],
d_id2: [
ReplicaSchedulingRequest(
replica_id=ReplicaID(unique_id="r1", deployment_id=d_id2),
actor_def=MockActorClass(),
actor_resources={"CPU": 1},
actor_options={},
actor_init_args=(),
on_scheduled=on_scheduled_mock2,
)
],
},
downscales={},
)
# The GPU replica must go to node 1 (only node with GPU).
assert len(on_scheduled_mock1.call_args_list) == 1
call1 = on_scheduled_mock1.call_args_list[0]
strategy1 = call1.args[0]._options["scheduling_strategy"]
assert isinstance(strategy1, NodeAffinitySchedulingStrategy)
assert strategy1.node_id == node_id_1
assert call1.kwargs == {"placement_group": None}
# The CPU replica should also go to node 1 (now non-idle) rather
# than node 2 (idle but tighter fit). The PACK scheduler prefers
# non-idle nodes to consolidate replicas onto fewer nodes.
assert len(on_scheduled_mock2.call_args_list) == 1
call2 = on_scheduled_mock2.call_args_list[0]
strategy2 = call2.args[0]._options["scheduling_strategy"]
assert isinstance(strategy2, NodeAffinitySchedulingStrategy)
assert strategy2.node_id == node_id_1
assert call2.kwargs == {"placement_group": None}
class TestScheduleGangPlacementGroups:
def test_schedule_gang_placement_groups(self, mock_deployment_state_manager):
"""Creates gangs successfully and verifies placement requests include expected bundles and strategy."""
captured_requests = []
gang_size = 2
num_gangs = 2
num_replicas_to_add = gang_size * num_gangs
replica_resource_dict = {"CPU": 2.0, "GPU": 1.0}
gang_strategy = "SPREAD"
def create_pg_fn(request: CreatePlacementGroupRequest, *args, **kwargs):
captured_requests.append(request)
return Mock()
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d1", app_name="app1")
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
gang_strategy,
num_replicas_to_add,
replica_resource_dict=replica_resource_dict,
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert deployment_id in result
reservation = result[deployment_id]
assert reservation.success
assert len(reservation.gang_pgs) == num_gangs
assert len(captured_requests) == num_gangs
for req in captured_requests:
assert isinstance(req, CreatePlacementGroupRequest)
assert req.bundles == [replica_resource_dict] * gang_size
assert req.strategy == gang_strategy
assert len(reservation.gang_ids) == num_gangs
assert len(reservation.gang_pg_names) == num_gangs
assert len(set(reservation.gang_ids)) == num_gangs
for pg_name in reservation.gang_pg_names:
assert pg_name.startswith(GANG_PG_NAME_PREFIX)
def test_schedule_gang_placement_groups_invalid_gang_size(
self, mock_deployment_state_manager
):
"""Returns failure when desired replicas cannot be evenly divided by gang size."""
gang_size = 3
num_replicas_to_add = 4
create_pg_fn = Mock()
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d2", app_name="app2")
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
"STRICT_PACK",
num_replicas_to_add,
{"CPU": 1.0},
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert not result[deployment_id].success
assert "not divisible by gang_size" in result[deployment_id].error_message
create_pg_fn.assert_not_called()
def test_schedule_gang_placement_groups_all_pg_creation_failures(
self, mock_deployment_state_manager
):
"""Reports failure when every gang placement group creation attempt raises exceptions."""
gang_size = 2
num_gangs = 2
num_replicas_to_add = gang_size * num_gangs
def create_pg_fn(request: CreatePlacementGroupRequest, *args, **kwargs):
raise RuntimeError("simulated placement group creation failure")
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d3", app_name="app3")
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
"STRICT_PACK",
num_replicas_to_add,
{"CPU": 1.0},
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert not result[deployment_id].success
assert (
"Failed to create any gang placement groups"
in result[deployment_id].error_message
)
def test_schedule_gang_placement_groups_partial_pg_creation_failures(
self, mock_deployment_state_manager
):
"""Keeps successful gang reservations when only a subset of placement groups fail."""
gang_size = 2
num_gangs = 2
num_replicas_to_add = gang_size * num_gangs
failed_gangs = 1
num_calls = 0
def create_pg_fn(request: CreatePlacementGroupRequest, *args, **kwargs):
nonlocal num_calls
num_calls += 1
if num_calls == 1:
raise RuntimeError("fail first gang only")
return Mock()
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d4", app_name="app4")
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
"STRICT_PACK",
num_replicas_to_add,
{"CPU": 1.0},
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert result[deployment_id].success
assert len(result[deployment_id].gang_pgs) == num_gangs - failed_gangs
def test_schedule_gang_placement_groups_with_per_replica_bundles(
self, mock_deployment_state_manager
):
"""Flattens per-replica bundles and propagates label selectors and fallback strategies correctly."""
captured_requests = []
gang_size = 2
num_gangs = 4
num_replicas_to_add = num_gangs * gang_size
def create_pg_fn(request: CreatePlacementGroupRequest, *args, **kwargs):
captured_requests.append(request)
return Mock()
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d5", app_name="app5")
per_replica_bundles = [{"GPU": 1.0, "CPU": 1.0}, {"CPU": 1.0}]
per_replica_label_selector = [{"gpu": "a100"}, {"zone": "z1"}]
per_replica_fallback = [{"allow_soft": True}, {"allow_soft": False}]
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
"STRICT_PACK",
num_replicas_to_add,
replica_resource_dict={"CPU": 1.0},
replica_placement_group_bundles=per_replica_bundles,
replica_pg_bundle_label_selector=per_replica_label_selector,
replica_pg_fallback_strategy=per_replica_fallback,
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert result[deployment_id].success
assert len(captured_requests) == num_gangs
expected_bundles = per_replica_bundles * gang_size
expected_label_selector = per_replica_label_selector * gang_size
expected_fallback = per_replica_fallback * gang_size
for req in captured_requests:
assert req.bundles == expected_bundles
assert req.bundle_label_selector == expected_label_selector
assert req.fallback_strategy == expected_fallback
def test_schedule_gang_placement_groups_without_per_replica_bundles_uses_resource_dict(
self, mock_deployment_state_manager
):
"""Uses replica resource dict for each gang bundle without optional selectors."""
captured_requests = []
gang_size = 3
num_gangs = 2
num_replicas_to_add = gang_size * num_gangs
replica_resource_dict = {"CPU": 2.0, "GPU": 0.5}
def create_pg_fn(request: CreatePlacementGroupRequest, *args, **kwargs):
captured_requests.append(request)
return Mock()
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id = DeploymentID(name="d6", app_name="app6")
gang_request = GangPlacementGroupRequest(
deployment_id,
gang_size,
"STRICT_PACK",
num_replicas_to_add,
replica_resource_dict=replica_resource_dict,
)
result = scheduler.schedule_gang_placement_groups({deployment_id: gang_request})
assert result[deployment_id].success
assert len(captured_requests) == num_gangs
for req in captured_requests:
assert req.bundles == [replica_resource_dict] * gang_size
assert req.bundle_label_selector is None
assert req.fallback_strategy is None
def test_schedule_gang_placement_groups_multiple_deployments(
self, mock_deployment_state_manager
):
"""Schedules gang placement groups for multiple deployments and returns independent results."""
create_pg_fn = Mock(return_value=Mock())
gang_size_1 = 2
num_gangs_1 = 2
num_replicas_to_add_1 = gang_size_1 * num_gangs_1
gang_size_2 = 3
num_gangs_2 = 2
num_replicas_to_add_2 = gang_size_2 * num_gangs_2
create_dsm, _, _, _ = mock_deployment_state_manager
dsm: DeploymentStateManager = create_dsm(
create_placement_group_fn_override=create_pg_fn,
)
scheduler = dsm._deployment_scheduler
deployment_id_1 = DeploymentID(name="d7", app_name="app7")
deployment_id_2 = DeploymentID(name="d8", app_name="app8")
gang_requests = {
deployment_id_1: GangPlacementGroupRequest(
deployment_id_1,
gang_size_1,
"STRICT_PACK",
num_replicas_to_add_1,
{"CPU": 1.0},
),
deployment_id_2: GangPlacementGroupRequest(
deployment_id_2,
gang_size_2,
"STRICT_PACK",
num_replicas_to_add_2,
{"CPU": 1.0},
),
}
result = scheduler.schedule_gang_placement_groups(gang_requests)
assert set(result.keys()) == {deployment_id_1, deployment_id_2}
assert result[deployment_id_1].success
assert result[deployment_id_2].success
assert len(result[deployment_id_1].gang_pgs) == num_gangs_1
assert len(result[deployment_id_2].gang_pgs) == num_gangs_2
assert create_pg_fn.call_count == num_gangs_1 + num_gangs_2
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))