chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,852 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._raylet import GcsClient
|
||||
from ray.serve._private import default_impl
|
||||
from ray.serve._private.common import DeploymentID, ReplicaID
|
||||
from ray.serve._private.constants import RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY
|
||||
from ray.serve._private.deployment_scheduler import (
|
||||
ReplicaSchedulingRequest,
|
||||
SpreadDeploymentSchedulingPolicy,
|
||||
)
|
||||
from ray.serve._private.test_utils import check_apps_running, get_node_id
|
||||
from ray.serve._private.utils import get_head_node_id
|
||||
from ray.tests.conftest import * # noqa
|
||||
|
||||
|
||||
@ray.remote(num_cpus=1)
|
||||
class Replica:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
def get_placement_group(self):
|
||||
return ray.util.get_current_placement_group()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Need to use spread strategy"
|
||||
)
|
||||
class TestSpreadScheduling:
|
||||
@pytest.mark.parametrize(
|
||||
"placement_group_config",
|
||||
[
|
||||
{},
|
||||
{"bundles": [{"CPU": 3}]},
|
||||
{
|
||||
"bundles": [{"CPU": 1}, {"CPU": 1}, {"CPU": 1}],
|
||||
"strategy": "STRICT_PACK",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_spread_deployment_scheduling_policy_upscale(
|
||||
self, ray_start_cluster, placement_group_config
|
||||
):
|
||||
"""Test to make sure replicas are spreaded."""
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(num_cpus=3)
|
||||
cluster.add_node(num_cpus=3)
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
cluster_node_info_cache = default_impl.create_cluster_node_info_cache(
|
||||
GcsClient(address=ray.get_runtime_context().gcs_address)
|
||||
)
|
||||
cluster_node_info_cache.update()
|
||||
|
||||
scheduler = default_impl.create_deployment_scheduler(
|
||||
cluster_node_info_cache,
|
||||
get_head_node_id(),
|
||||
)
|
||||
dep_id = DeploymentID(name="deployment1")
|
||||
r1_id = ReplicaID(unique_id="replica1", deployment_id=dep_id)
|
||||
r2_id = ReplicaID(unique_id="replica2", deployment_id=dep_id)
|
||||
scheduler.on_deployment_created(dep_id, SpreadDeploymentSchedulingPolicy())
|
||||
replica_actor_handles = []
|
||||
replica_placement_groups = []
|
||||
|
||||
def on_scheduled(actor_handle, placement_group):
|
||||
replica_actor_handles.append(actor_handle)
|
||||
replica_placement_groups.append(placement_group)
|
||||
|
||||
deployment_to_replicas_to_stop = scheduler.schedule(
|
||||
upscales={
|
||||
dep_id: [
|
||||
ReplicaSchedulingRequest(
|
||||
replica_id=r1_id,
|
||||
actor_def=Replica,
|
||||
actor_resources={"CPU": 1},
|
||||
actor_options={"name": "deployment1_replica1"},
|
||||
actor_init_args=(),
|
||||
on_scheduled=on_scheduled,
|
||||
placement_group_bundles=placement_group_config.get(
|
||||
"bundles", None
|
||||
),
|
||||
placement_group_strategy=placement_group_config.get(
|
||||
"strategy", None
|
||||
),
|
||||
),
|
||||
ReplicaSchedulingRequest(
|
||||
replica_id=r2_id,
|
||||
actor_def=Replica,
|
||||
actor_resources={"CPU": 1},
|
||||
actor_options={"name": "deployment1_replica2"},
|
||||
actor_init_args=(),
|
||||
on_scheduled=on_scheduled,
|
||||
placement_group_bundles=placement_group_config.get(
|
||||
"bundles", None
|
||||
),
|
||||
placement_group_strategy=placement_group_config.get(
|
||||
"strategy", None
|
||||
),
|
||||
),
|
||||
]
|
||||
},
|
||||
downscales={},
|
||||
)
|
||||
assert not deployment_to_replicas_to_stop
|
||||
assert len(replica_actor_handles) == 2
|
||||
assert len(replica_placement_groups) == 2
|
||||
assert not scheduler._pending_replicas[dep_id]
|
||||
assert len(scheduler._launching_replicas[dep_id]) == 2
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
ray.get(replica_actor_handles[0].get_node_id.remote()),
|
||||
ray.get(replica_actor_handles[1].get_node_id.remote()),
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
if "bundles" in placement_group_config:
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
ray.get(replica_actor_handles[0].get_placement_group.remote()),
|
||||
ray.get(replica_actor_handles[1].get_placement_group.remote()),
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
scheduler.on_replica_stopping(r1_id)
|
||||
scheduler.on_replica_stopping(r2_id)
|
||||
scheduler.on_deployment_deleted(dep_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spread_serve_strict_spread_pg(self, ray_cluster):
|
||||
"""
|
||||
Verifies STRICT_SPREAD PG strategy runs successfully in the Spread Scheduler
|
||||
and spreads bundles across distinct nodes.
|
||||
"""
|
||||
cluster = ray_cluster
|
||||
cluster.add_node(num_cpus=3)
|
||||
cluster.add_node(num_cpus=3)
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
serve.start()
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
def get_task_node_id():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
|
||||
placement_group_strategy="STRICT_SPREAD",
|
||||
)
|
||||
class StrictSpread:
|
||||
async def get_bundle_node_id(self, bundle_index: int):
|
||||
pg = ray.util.get_current_placement_group()
|
||||
return await get_task_node_id.options(
|
||||
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
|
||||
placement_group=pg,
|
||||
placement_group_bundle_index=bundle_index,
|
||||
)
|
||||
).remote()
|
||||
|
||||
handle = serve.run(StrictSpread.bind(), name="strict_spread_app")
|
||||
|
||||
node_0 = await handle.get_bundle_node_id.remote(0)
|
||||
node_1 = await handle.get_bundle_node_id.remote(1)
|
||||
|
||||
assert node_0 != node_1
|
||||
|
||||
serve.delete("strict_spread_app")
|
||||
serve.shutdown()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
def A():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
|
||||
app_A = A.bind()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs pack strategy."
|
||||
)
|
||||
class TestPackScheduling:
|
||||
@pytest.mark.parametrize("use_pg", [True, False])
|
||||
def test_e2e_basic(self, ray_cluster, use_pg: bool):
|
||||
cluster = ray_cluster
|
||||
cluster.add_node(num_cpus=2, resources={"head": 1})
|
||||
cluster.add_node(num_cpus=3, resources={"worker1": 1})
|
||||
cluster.add_node(num_cpus=4, resources={"worker2": 1})
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
head_node_id = ray.get(get_node_id.options(resources={"head": 1}).remote())
|
||||
worker1_node_id = ray.get(
|
||||
get_node_id.options(resources={"worker1": 1}).remote()
|
||||
)
|
||||
worker2_node_id = ray.get(
|
||||
get_node_id.options(resources={"worker2": 1}).remote()
|
||||
)
|
||||
print("head", head_node_id)
|
||||
print("worker1", worker1_node_id)
|
||||
print("worker2", worker2_node_id)
|
||||
|
||||
# Both f replicas should be scheduled on head node to minimize
|
||||
# fragmentation
|
||||
if use_pg:
|
||||
app1 = A.options(
|
||||
num_replicas=2,
|
||||
ray_actor_options={"num_cpus": 0.1},
|
||||
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5}],
|
||||
placement_group_strategy="STRICT_PACK",
|
||||
).bind()
|
||||
else:
|
||||
app1 = A.options(num_replicas=2, ray_actor_options={"num_cpus": 1}).bind()
|
||||
|
||||
# Both app1 replicas should have been scheduled on head node
|
||||
f_handle = serve.run(app1, name="app1", route_prefix="/app1")
|
||||
refs = [f_handle.remote() for _ in range(20)]
|
||||
assert {ref.result() for ref in refs} == {head_node_id}
|
||||
|
||||
if use_pg:
|
||||
app2 = A.options(
|
||||
num_replicas=1,
|
||||
ray_actor_options={"num_cpus": 0.1},
|
||||
placement_group_bundles=[{"CPU": 1}, {"CPU": 2}],
|
||||
placement_group_strategy="STRICT_PACK",
|
||||
).bind()
|
||||
else:
|
||||
app2 = A.options(num_replicas=1, ray_actor_options={"num_cpus": 3}).bind()
|
||||
|
||||
# Then there should be enough space for the g replica
|
||||
# The g replica should be scheduled on worker1, not worker2, to
|
||||
# minimize fragmentation
|
||||
g_handle = serve.run(app2, name="app2", route_prefix="/app2")
|
||||
assert g_handle.remote().result() == worker1_node_id
|
||||
|
||||
serve.shutdown()
|
||||
|
||||
@pytest.mark.parametrize("use_pg", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"app_resources,expected_worker_nodes",
|
||||
[
|
||||
# [2, 5, 3, 3, 7, 6, 4] -> 3 nodes
|
||||
({5: 1, 3: 2, 7: 1, 2: 1, 6: 1, 4: 1}, 3),
|
||||
# [1, 7, 7, 3, 2] -> 2 nodes
|
||||
({1: 1, 7: 2, 3: 1, 2: 1}, 2),
|
||||
# [7, 3, 2, 7, 7, 2] -> 3 nodes
|
||||
({7: 3, 3: 1, 2: 2}, 3),
|
||||
],
|
||||
)
|
||||
def test_e2e_fit_replicas(
|
||||
self, ray_cluster, use_pg, app_resources, expected_worker_nodes
|
||||
):
|
||||
for _ in range(expected_worker_nodes):
|
||||
ray_cluster.add_node(num_cpus=1)
|
||||
ray_cluster.wait_for_nodes()
|
||||
ray.init(address=ray_cluster.address)
|
||||
|
||||
serve.start()
|
||||
|
||||
@serve.deployment
|
||||
def A():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
||||
class Ingress:
|
||||
def __init__(self, *handles):
|
||||
self.handles = handles
|
||||
|
||||
def __call__(self):
|
||||
pass
|
||||
|
||||
deployments = []
|
||||
for n, count in app_resources.items():
|
||||
num_cpus = 0.1 * n
|
||||
deployments.append(
|
||||
A.options(
|
||||
name=f"A{n}",
|
||||
num_replicas=count,
|
||||
ray_actor_options={"num_cpus": 0 if use_pg else num_cpus},
|
||||
placement_group_bundles=[{"CPU": num_cpus}] if use_pg else None,
|
||||
placement_group_strategy="STRICT_PACK" if use_pg else None,
|
||||
).bind()
|
||||
)
|
||||
|
||||
serve.run(Ingress.bind(*deployments))
|
||||
wait_for_condition(check_apps_running, apps=["default"])
|
||||
print("Test passed!")
|
||||
|
||||
@pytest.mark.parametrize("use_pg", [True, False])
|
||||
def test_e2e_custom_resources(self, ray_cluster, use_pg):
|
||||
cluster = ray_cluster
|
||||
cluster.add_node(num_cpus=1, resources={"head": 1})
|
||||
cluster.add_node(num_cpus=3, resources={"worker1": 1, "customabcd": 1})
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
worker1_node_id = ray.get(
|
||||
get_node_id.options(resources={"worker1": 1}).remote()
|
||||
)
|
||||
|
||||
if use_pg:
|
||||
app = A.options(
|
||||
num_replicas=1,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
placement_group_bundles=[{"CPU": 0.5}, {"CPU": 0.5, "customabcd": 0.1}],
|
||||
placement_group_strategy="STRICT_PACK",
|
||||
).bind()
|
||||
else:
|
||||
app = A.options(
|
||||
num_replicas=1,
|
||||
ray_actor_options={"num_cpus": 1, "resources": {"customabcd": 0.1}},
|
||||
).bind()
|
||||
|
||||
handle1 = serve.run(app, name="app1", route_prefix="/app1")
|
||||
refs = [handle1.remote() for _ in range(20)]
|
||||
assert all(ref.result() == worker1_node_id for ref in refs)
|
||||
|
||||
serve.shutdown()
|
||||
|
||||
def test_high_priority_memory_schedules_before_cpu_hogs(
|
||||
self, ray_cluster, monkeypatch
|
||||
):
|
||||
"""Memory in RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES overrides CPU priority.
|
||||
|
||||
Pack scheduling sorts pending replicas by ``Resources.__lt__``. By default
|
||||
CPU is compared before memory, so a deployment with higher ``num_cpus``
|
||||
is scheduled first when only one replica fits on the node.
|
||||
|
||||
Here each ``cpu_i`` requests ``replica_cpus`` CPUs while ``memory_hog``
|
||||
requests ``replica_cpus - 1`` CPUs plus most of the node memory. Without
|
||||
the env var, a ``cpu_i`` deployment would win; with
|
||||
``RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES=memory``, ``memory_hog`` must
|
||||
be the only deployment that reaches RUNNING.
|
||||
"""
|
||||
monkeypatch.setenv("RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES", "memory")
|
||||
|
||||
cluster = ray_cluster
|
||||
cluster.add_node(num_cpus=4)
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
total_memory = int(ray.cluster_resources()["memory"])
|
||||
high_memory = max(int(total_memory * 0.9), 1)
|
||||
|
||||
@serve.deployment
|
||||
def replica_fn():
|
||||
return "ok"
|
||||
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
||||
class Ingress:
|
||||
def __init__(self, *handles):
|
||||
self.handles = handles
|
||||
|
||||
def __call__(self):
|
||||
pass
|
||||
|
||||
# Reserve head/proxy CPU by measuring what Serve leaves available.
|
||||
serve.start()
|
||||
replica_cpus = int(ray.available_resources()["CPU"])
|
||||
assert replica_cpus >= 2, "Need at least 2 CPUs for cpu vs memory_hog split"
|
||||
|
||||
memory_hog_cpus = replica_cpus - 1
|
||||
|
||||
deployments = []
|
||||
for i in range(9):
|
||||
deployments.append(
|
||||
replica_fn.options(
|
||||
name=f"cpu_{i}",
|
||||
# Higher CPU than memory_hog: would sort first by default.
|
||||
ray_actor_options={"num_cpus": replica_cpus},
|
||||
).bind()
|
||||
)
|
||||
deployments.append(
|
||||
replica_fn.options(
|
||||
name="memory_hog",
|
||||
ray_actor_options={
|
||||
"num_cpus": memory_hog_cpus,
|
||||
"memory": high_memory,
|
||||
},
|
||||
).bind()
|
||||
)
|
||||
|
||||
serve._run(Ingress.bind(*deployments), _blocking=False)
|
||||
|
||||
def check_only_memory_hog_running():
|
||||
app_status = serve.status().applications["default"]
|
||||
if app_status.status == "DEPLOY_FAILED":
|
||||
raise AssertionError(f"App failed: {app_status.message}")
|
||||
|
||||
deployments_status = app_status.deployments
|
||||
memory_hog = deployments_status["memory_hog"]
|
||||
assert memory_hog.replica_states.get("RUNNING", 0) == 1, memory_hog
|
||||
|
||||
for i in range(9):
|
||||
cpu_dep = deployments_status[f"cpu_{i}"]
|
||||
running = cpu_dep.replica_states.get("RUNNING", 0)
|
||||
assert running == 0, (f"cpu_{i}", cpu_dep.replica_states)
|
||||
|
||||
return True
|
||||
|
||||
wait_for_condition(check_only_memory_hog_running, timeout=60)
|
||||
|
||||
app_status = serve.status().applications["default"]
|
||||
assert app_status.status == "DEPLOYING"
|
||||
|
||||
# Add a second node: pack logs should show a new schedule-order batch
|
||||
# and one cpu_* replica placed on the new node while memory_hog stays up.
|
||||
cluster.add_node(num_cpus=4)
|
||||
cluster.wait_for_nodes()
|
||||
|
||||
def check_one_cpu_replica_after_scale_out():
|
||||
app_status = serve.status().applications["default"]
|
||||
if app_status.status == "DEPLOY_FAILED":
|
||||
raise AssertionError(f"App failed: {app_status.message}")
|
||||
|
||||
deployments_status = app_status.deployments
|
||||
assert (
|
||||
deployments_status["memory_hog"].replica_states.get("RUNNING", 0) == 1
|
||||
)
|
||||
|
||||
cpu_running = sum(
|
||||
deployments_status[f"cpu_{i}"].replica_states.get("RUNNING", 0)
|
||||
for i in range(9)
|
||||
)
|
||||
assert cpu_running == 1, {
|
||||
f"cpu_{i}": deployments_status[f"cpu_{i}"].replica_states
|
||||
for i in range(9)
|
||||
}
|
||||
return True
|
||||
|
||||
wait_for_condition(check_one_cpu_replica_after_scale_out, timeout=60)
|
||||
|
||||
app_status = serve.status().applications["default"]
|
||||
assert app_status.status == "DEPLOYING"
|
||||
|
||||
serve.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_strict_pack_pg_label_selector(
|
||||
self, serve_instance_with_labeled_nodes
|
||||
):
|
||||
"""
|
||||
Verifies STRICT_PACK strategy with placement_group_bundle_label_selector in Pack Scheduling Mode.
|
||||
|
||||
Since the strategy is STRICT_PACK, both bundles must be scheduled on the same node,
|
||||
and that node must satisfy the label constraints in each selector.
|
||||
"""
|
||||
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
def get_task_node_id():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
|
||||
placement_group_strategy="STRICT_PACK",
|
||||
placement_group_bundle_label_selector=[
|
||||
{"gpu-type": "H100", "region": "us-east"}
|
||||
],
|
||||
)
|
||||
class StrictPackSelector:
|
||||
async def get_bundle_node_id(self, bundle_index: int):
|
||||
pg = ray.util.get_current_placement_group()
|
||||
return await get_task_node_id.options(
|
||||
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
|
||||
placement_group=pg,
|
||||
placement_group_bundle_index=bundle_index,
|
||||
)
|
||||
).remote()
|
||||
|
||||
handle = serve.run(StrictPackSelector.bind(), name="strict_pack_app")
|
||||
|
||||
# Both bundles are scheduled to the same node which matches the label constraints.
|
||||
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
|
||||
assert await handle.get_bundle_node_id.remote(1) == us_east_node_id
|
||||
|
||||
serve.delete("strict_pack_app")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_pack_pg_forces_spread(
|
||||
self, serve_instance_with_labeled_nodes
|
||||
):
|
||||
"""
|
||||
Verifies that using non-strict PACK PG strategy with label selectors works.
|
||||
|
||||
STRICT_PACK throws NotImplementedError for selectors. However, 'PACK' is considered a
|
||||
'Non-Strict' strategy which forces the scheduler to fall back to 'Spread Mode'.
|
||||
"""
|
||||
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}],
|
||||
placement_group_strategy="PACK",
|
||||
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
|
||||
)
|
||||
class PackSelector:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
# If this stayed in the Pack Scheduler, it would raise NotImplementedError.
|
||||
# Because it forces Spread Mode, it succeeds.
|
||||
handle = serve.run(PackSelector.bind(), name="pack_selector_app")
|
||||
assert await handle.get_node_id.remote() == us_east_node_id
|
||||
serve.delete("pack_selector_app")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_multiple_bundles_selector(
|
||||
self, serve_instance_with_labeled_nodes
|
||||
):
|
||||
"""Verifies multiple bundles with bundle_label_selector are applied correctly."""
|
||||
_, us_west_node_id, us_east_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
# Helper task to return the node ID it's running on
|
||||
@ray.remote(num_cpus=0)
|
||||
def get_task_node_id():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
|
||||
placement_group_strategy="SPREAD",
|
||||
placement_group_bundle_label_selector=[
|
||||
{"gpu-type": "H100"}, # matches us-east node
|
||||
{"gpu-type": "A100"}, # matches us-west node
|
||||
],
|
||||
)
|
||||
class MultiBundleSelector:
|
||||
async def get_bundle_node_id(self, bundle_index: int):
|
||||
pg = ray.util.get_current_placement_group()
|
||||
return await get_task_node_id.options(
|
||||
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
|
||||
placement_group=pg,
|
||||
placement_group_bundle_index=bundle_index,
|
||||
)
|
||||
).remote()
|
||||
|
||||
handle = serve.run(MultiBundleSelector.bind(), name="multi_bundle_app")
|
||||
|
||||
# Verify bundles are scheduled to expected nodes based on label selectors.
|
||||
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
|
||||
assert await handle.get_bundle_node_id.remote(1) == us_west_node_id
|
||||
serve.delete("multi_bundle_app")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_multiple_bundles_single_bundle_label_selector(
|
||||
self, serve_instance_with_labeled_nodes
|
||||
):
|
||||
"""
|
||||
Verifies that when only one bundle_label_selector is provided for multiple bundles,
|
||||
the label_selector is applied to each bundle uniformly.
|
||||
"""
|
||||
_, _, us_east_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
def get_task_node_id():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
|
||||
# Use SPREAD to verify the label constraint forces them to same node.
|
||||
placement_group_strategy="SPREAD",
|
||||
placement_group_bundle_label_selector=[
|
||||
{"gpu-type": "H100"},
|
||||
],
|
||||
)
|
||||
class MultiBundleSelector:
|
||||
async def get_bundle_node_id(self, bundle_index: int):
|
||||
pg = ray.util.get_current_placement_group()
|
||||
return await get_task_node_id.options(
|
||||
scheduling_strategy=ray.util.scheduling_strategies.PlacementGroupSchedulingStrategy(
|
||||
placement_group=pg,
|
||||
placement_group_bundle_index=bundle_index,
|
||||
)
|
||||
).remote()
|
||||
|
||||
handle = serve.run(MultiBundleSelector.bind(), name="multi_bundle_app")
|
||||
assert await handle.get_bundle_node_id.remote(0) == us_east_node_id
|
||||
assert await handle.get_bundle_node_id.remote(1) == us_east_node_id
|
||||
serve.delete("multi_bundle_app")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_actor_multiple_fallbacks(
|
||||
self, serve_instance_with_labeled_nodes
|
||||
):
|
||||
"""
|
||||
Verifies that the scheduler can iterate through a label selector and multiple fallback options.
|
||||
"""
|
||||
_, us_west_node_id, _, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
@serve.deployment(
|
||||
ray_actor_options={
|
||||
"label_selector": {"region": "invalid-label-1"},
|
||||
"fallback_strategy": [
|
||||
{"label_selector": {"region": "invalid-label-2"}},
|
||||
{"label_selector": {"region": "us-west"}}, # Should match
|
||||
],
|
||||
}
|
||||
)
|
||||
class MultiFallbackActor:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
handle = serve.run(MultiFallbackActor.bind(), name="multi_fallback_app")
|
||||
assert await handle.get_node_id.remote() == us_west_node_id
|
||||
serve.delete("multi_fallback_app")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_label_selector(serve_instance_with_labeled_nodes):
|
||||
"""
|
||||
Verifies that label selectors work correctly for both Actors and Placement Groups.
|
||||
|
||||
This test also verifies that label selectors are respected when scheduling with a
|
||||
preferred node ID for resource compaction. This test verifies both the Pack and
|
||||
Spread scheduler paths.
|
||||
"""
|
||||
_, us_west_node_id, us_east_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
# Validate a Serve deplyoment utilizes a label_selector when passed to the Ray Actor options.
|
||||
@serve.deployment(ray_actor_options={"label_selector": {"region": "us-west"}})
|
||||
class DeploymentActor:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
handle = serve.run(DeploymentActor.bind(), name="actor_app")
|
||||
assert await handle.get_node_id.remote() == us_west_node_id
|
||||
serve.delete("actor_app")
|
||||
|
||||
# Validate placement_group scheduling strategy with placement_group_bundle_label_selector
|
||||
# and PACK strategy.
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}],
|
||||
placement_group_strategy="PACK",
|
||||
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
|
||||
)
|
||||
class DeploymentPGPack:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
handle_pack = serve.run(DeploymentPGPack.bind(), name="pg_pack_app")
|
||||
assert await handle_pack.get_node_id.remote() == us_east_node_id
|
||||
serve.delete("pg_pack_app")
|
||||
|
||||
# Validate placement_group scheduling strategy with placement_group_bundle_label_selector
|
||||
# and SPREAD strategy.
|
||||
@serve.deployment(
|
||||
placement_group_bundles=[{"CPU": 1}],
|
||||
placement_group_strategy="SPREAD",
|
||||
placement_group_bundle_label_selector=[{"gpu-type": "H100"}],
|
||||
)
|
||||
class DeploymentPGSpread:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
handle_spread = serve.run(DeploymentPGSpread.bind(), name="pg_spread_app")
|
||||
assert await handle_spread.get_node_id.remote() == us_east_node_id
|
||||
serve.delete("pg_spread_app")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_fallback_strategy(serve_instance_with_labeled_nodes):
|
||||
"""
|
||||
Verifies that fallback strategies allow scheduling on alternative nodes when
|
||||
primary constraints fail.
|
||||
"""
|
||||
_, _, h100_node_id, _ = serve_instance_with_labeled_nodes
|
||||
|
||||
# Fallback strategy specified for Ray Actor in Serve deployment.
|
||||
@serve.deployment(
|
||||
ray_actor_options={
|
||||
"label_selector": {"region": "unavailable"},
|
||||
"fallback_strategy": [{"label_selector": {"gpu-type": "H100"}}],
|
||||
}
|
||||
)
|
||||
class FallbackDeployment:
|
||||
def get_node_id(self):
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
# TODO (ryanaoleary@): Add a test for fallback_strategy in placement group options
|
||||
# when support is added.
|
||||
|
||||
handle = serve.run(FallbackDeployment.bind(), name="fallback_app")
|
||||
assert await handle.get_node_id.remote() == h100_node_id
|
||||
serve.delete("fallback_app")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"use_pg,strategy",
|
||||
[
|
||||
(False, None), # Actor-level label_selector.
|
||||
(True, "PACK"), # PG bundle_label_selector with PACK strategy.
|
||||
(True, "STRICT_PACK"), # PG bundle_label_selector with STRICT_PACK strategy.
|
||||
(True, "SPREAD"), # PG bundle_label_selector with SPREAD strategy.
|
||||
(
|
||||
True,
|
||||
"STRICT_SPREAD",
|
||||
), # PG bundle_label_selector with STRICT_SPREAD strategy.
|
||||
],
|
||||
)
|
||||
async def test_e2e_serve_label_selector_unschedulable(
|
||||
serve_instance_with_labeled_nodes, use_pg, strategy
|
||||
):
|
||||
"""
|
||||
Verifies the interaction between unschedulable a placement_group_bundle_label_selector
|
||||
and different scheduling strategies in the Pack and Spread Serve scheduler.
|
||||
"""
|
||||
_, _, _, cluster = serve_instance_with_labeled_nodes
|
||||
|
||||
@serve.deployment
|
||||
def A():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
# Cluster in fixture only contains us-west and us-east.
|
||||
target_label = {"region": "eu-central"}
|
||||
|
||||
if use_pg:
|
||||
app = A.options(
|
||||
num_replicas=1,
|
||||
placement_group_bundles=[{"CPU": 1}],
|
||||
placement_group_strategy=strategy,
|
||||
placement_group_bundle_label_selector=[target_label],
|
||||
).bind()
|
||||
else:
|
||||
app = A.options(
|
||||
num_replicas=1,
|
||||
ray_actor_options={"label_selector": target_label},
|
||||
).bind()
|
||||
|
||||
handle = serve._run(app, name="unschedulable_label_app", _blocking=False)
|
||||
|
||||
def check_status(expected_status):
|
||||
try:
|
||||
status_info = serve.status().applications["unschedulable_label_app"]
|
||||
return status_info.status == expected_status
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def verify_resource_request_stuck():
|
||||
"""Verifies that the underlying resource request is pending."""
|
||||
# Serve deployment should be stuck DEPLOYING.
|
||||
if not check_status("DEPLOYING"):
|
||||
return False
|
||||
|
||||
# Check PG/Actor is actually pending.
|
||||
if use_pg:
|
||||
pgs = ray.util.state.list_placement_groups()
|
||||
return any(pg["state"] == "PENDING" for pg in pgs)
|
||||
else:
|
||||
actors = ray.util.state.list_actors()
|
||||
return any(a["state"] == "PENDING_CREATION" for a in actors)
|
||||
|
||||
# Serve deployment should remain stuck in deploying because Actor/PG can't be scheduled.
|
||||
wait_for_condition(verify_resource_request_stuck, timeout=30)
|
||||
assert not check_status("RUNNING"), (
|
||||
"Test setup failed: The deployment became RUNNING before the required "
|
||||
"node was added. The label selector constraint was ignored."
|
||||
)
|
||||
|
||||
# Add a suitable node to the cluster.
|
||||
new_node = cluster.add_node(
|
||||
num_cpus=2, labels=target_label, resources={"target_node": 1}
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
expected_node_id = ray.get(
|
||||
get_node_id.options(resources={"target_node": 1}).remote()
|
||||
)
|
||||
|
||||
# Validate deployment can now be scheduled since label selector is satisfied.
|
||||
wait_for_condition(lambda: check_status("RUNNING"), timeout=30)
|
||||
assert await handle.remote() == expected_node_id
|
||||
|
||||
serve.delete("unschedulable_label_app")
|
||||
cluster.remove_node(new_node)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_serve_fallback_strategy_unschedulable(
|
||||
serve_instance_with_labeled_nodes,
|
||||
):
|
||||
"""
|
||||
Verifies that an unschedulable fallback_strategy causes the Serve deployment to wait
|
||||
until a suitable node is added to the cluster.
|
||||
"""
|
||||
_, _, _, cluster = serve_instance_with_labeled_nodes
|
||||
|
||||
@serve.deployment
|
||||
def A():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
fallback_label = {"region": "me-central2"}
|
||||
|
||||
app = A.options(
|
||||
num_replicas=1,
|
||||
ray_actor_options={
|
||||
"label_selector": {"region": "non-existant"},
|
||||
"fallback_strategy": [{"label_selector": fallback_label}],
|
||||
},
|
||||
).bind()
|
||||
|
||||
handle = serve._run(app, name="unschedulable_fallback_app", _blocking=False)
|
||||
|
||||
def check_status(expected_status):
|
||||
try:
|
||||
status_info = serve.status().applications["unschedulable_fallback_app"]
|
||||
return status_info.status == expected_status
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def verify_resource_request_stuck():
|
||||
"""Verifies that the underlying resource request is pending."""
|
||||
# Serve deployment should be stuck DEPLOYING.
|
||||
if not check_status("DEPLOYING"):
|
||||
return False
|
||||
|
||||
actors = ray.util.state.list_actors()
|
||||
return any(a["state"] == "PENDING_CREATION" for a in actors)
|
||||
|
||||
# Serve deployment should remain stuck in deploying because Actor/PG can't be scheduled.
|
||||
wait_for_condition(verify_resource_request_stuck, timeout=30)
|
||||
assert not check_status("RUNNING"), (
|
||||
"Test setup failed: The deployment became RUNNING before the required "
|
||||
"node was added. The label selector constraint was ignored."
|
||||
)
|
||||
|
||||
# Add a node that matches the fallback.
|
||||
new_node = cluster.add_node(
|
||||
num_cpus=2, labels=fallback_label, resources={"fallback_node": 1}
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
expected_node_id = ray.get(
|
||||
get_node_id.options(resources={"fallback_node": 1}).remote()
|
||||
)
|
||||
|
||||
# The serve deployment should recover and start running on the fallback node.
|
||||
wait_for_condition(lambda: check_status("RUNNING"), timeout=30)
|
||||
assert await handle.remote() == expected_node_id
|
||||
|
||||
serve.delete("unschedulable_fallback_app")
|
||||
cluster.remove_node(new_node)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
Reference in New Issue
Block a user