1983 lines
73 KiB
Python
1983 lines
73 KiB
Python
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
import pytest
|
||
|
||
import ray
|
||
from ray import serve
|
||
from ray._common.test_utils import SignalActor, wait_for_condition
|
||
from ray.serve._private.common import GANG_PG_NAME_PREFIX, DeploymentID, ReplicaState
|
||
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
|
||
from ray.serve._private.test_utils import (
|
||
Accumulator,
|
||
FailedGangReplicaStore,
|
||
check_apps_running,
|
||
check_num_replicas_eq,
|
||
)
|
||
from ray.serve._private.utils import get_all_live_placement_group_names
|
||
from ray.serve.config import GangPlacementStrategy, GangSchedulingConfig
|
||
from ray.serve.context import _get_global_client
|
||
from ray.tests.conftest import * # noqa
|
||
from ray.util.placement_group import get_current_placement_group, placement_group_table
|
||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||
|
||
|
||
def _get_running_replicas(deployment_id: DeploymentID):
|
||
"""Return RUNNING replicas for a deployment from controller state."""
|
||
controller = _get_global_client()._controller
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(deployment_id)
|
||
)
|
||
return replicas.get([ReplicaState.RUNNING])
|
||
|
||
|
||
def _get_gang_ids_from_running(running) -> set:
|
||
return {r.gang_context.gang_id for r in running if r.gang_context is not None}
|
||
|
||
|
||
def _get_node_ids_from_running(running) -> set:
|
||
return {r.actor_node_id for r in running if r.actor_node_id}
|
||
|
||
|
||
class TestGangScheduling:
|
||
"""Tests for gang scheduling with placement groups."""
|
||
|
||
def test_sufficient_resources(self, ray_cluster):
|
||
"""Verifies that gang scheduling succeeds when cluster has sufficient resources."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=8,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||
)
|
||
class GangDeployment:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
handle = serve.run(GangDeployment.bind(), name="gang_app_success")
|
||
wait_for_condition(
|
||
check_apps_running,
|
||
apps=["gang_app_success"],
|
||
)
|
||
|
||
# Verify all replicas are running and responding
|
||
refs = [handle.remote() for _ in range(8)]
|
||
results = [ref.result() for ref in refs]
|
||
assert len(results) == 8
|
||
|
||
serve.delete("gang_app_success")
|
||
serve.shutdown()
|
||
|
||
def test_sufficient_resources_with_options(self, ray_cluster):
|
||
"""Verifies gang scheduling via .options() succeeds and responds to requests."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(num_replicas=1, ray_actor_options={"num_cpus": 0})
|
||
class GangDeployment:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
app = GangDeployment.options(
|
||
num_replicas=8,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||
).bind()
|
||
|
||
handle = serve.run(app, name="gang_app_options")
|
||
wait_for_condition(
|
||
check_apps_running,
|
||
apps=["gang_app_options"],
|
||
)
|
||
|
||
# Verify all replicas are running and responding
|
||
refs = [handle.remote() for _ in range(8)]
|
||
results = [ref.result() for ref in refs]
|
||
assert len(results) == 8
|
||
|
||
serve.delete("gang_app_options")
|
||
serve.shutdown()
|
||
|
||
def test_incomplete_deployment(self, ray_cluster):
|
||
"""
|
||
Verifies that schedulable gangs serve traffic while unschedulable gangs wait for resources.
|
||
"""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment
|
||
class IncompleteGangDeployment:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
app = IncompleteGangDeployment.options(
|
||
num_replicas=12,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||
).bind()
|
||
|
||
handle = serve._run(app, name="gang_partial_app", _blocking=False)
|
||
|
||
# The deployment should NOT fail. 2 of 3 gangs should be scheduled,
|
||
# and those 8 replicas should serve traffic. The deployment stays
|
||
# DEPLOYING because it hasn't reached 12 replicas.
|
||
def check_replicas_running(expected_count: int):
|
||
try:
|
||
app_status = serve.status().applications["gang_partial_app"]
|
||
# Should be DEPLOYING
|
||
if app_status.status == "DEPLOY_FAILED":
|
||
raise AssertionError(
|
||
"Deployment should not fail with partial gang scheduling"
|
||
)
|
||
# Check that some replicas are running
|
||
dep_status = list(app_status.deployments.values())[0]
|
||
running = dep_status.replica_states.get("RUNNING", 0)
|
||
assert running == expected_count
|
||
return True
|
||
except KeyError:
|
||
return False
|
||
|
||
wait_for_condition(check_replicas_running, expected_count=8, timeout=60)
|
||
|
||
# Verify the running replicas can serve traffic.
|
||
results = set()
|
||
for _ in range(40):
|
||
results.add(handle.remote().result())
|
||
assert len(results) > 0
|
||
|
||
# Verify deployment is still DEPLOYING
|
||
app_status = serve.status().applications["gang_partial_app"]
|
||
assert app_status.status == "DEPLOYING"
|
||
|
||
# Now add a 3rd node so the remaining gang can be scheduled.
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
|
||
# The deployment should become RUNNING with all 12 replicas.
|
||
wait_for_condition(
|
||
check_apps_running,
|
||
apps=["gang_partial_app"],
|
||
timeout=60,
|
||
)
|
||
|
||
# Verify all 12 replicas are running across 3 nodes (controller state,
|
||
# not handle routing, which may only hit local replicas).
|
||
dep_id = DeploymentID(
|
||
name="IncompleteGangDeployment", app_name="gang_partial_app"
|
||
)
|
||
running = _get_running_replicas(dep_id)
|
||
assert len(running) == 12
|
||
assert len(_get_node_ids_from_running(running)) == 3
|
||
|
||
serve.delete("gang_partial_app")
|
||
serve.shutdown()
|
||
|
||
def test_no_partial_gang(self, ray_cluster):
|
||
"""Verifies atomic gang scheduling: no partial gangs are created."""
|
||
cluster = ray_cluster
|
||
# 2 CPUs total: enough for 2 full gangs (1.6 CPUs) but not 3 (2.4 CPUs).
|
||
# The leftover 0.4 CPUs must NOT produce a partial gang.
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment
|
||
class AtomicGangDeployment:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
app = AtomicGangDeployment.options(
|
||
num_replicas=12,
|
||
ray_actor_options={"num_cpus": 0.2},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||
).bind()
|
||
|
||
handle = serve._run(app, name="atomic_gang_app", _blocking=False)
|
||
|
||
# Wait until exactly 8 replicas (2 gangs) are running.
|
||
def check_replicas_running(expected_count: int):
|
||
try:
|
||
app_status = serve.status().applications["atomic_gang_app"]
|
||
if app_status.status == "DEPLOY_FAILED":
|
||
raise AssertionError(
|
||
"Deployment should not fail — partial gangs should "
|
||
"serve traffic while waiting for resources."
|
||
)
|
||
dep_status = list(app_status.deployments.values())[0]
|
||
running = dep_status.replica_states.get("RUNNING", 0)
|
||
assert running == expected_count
|
||
return True
|
||
except KeyError:
|
||
return False
|
||
|
||
wait_for_condition(check_replicas_running, expected_count=8, timeout=60)
|
||
|
||
# Deployment should still be DEPLOYING (not RUNNING, not DEPLOY_FAILED).
|
||
app_status = serve.status().applications["atomic_gang_app"]
|
||
assert app_status.status == "DEPLOYING"
|
||
|
||
# Verify the 8 running replicas can serve traffic.
|
||
results = set()
|
||
for _ in range(80):
|
||
results.add(handle.remote().result())
|
||
assert len(results) > 0
|
||
|
||
# Add 1 more CPU so the 3rd gang (0.8 CPUs) can be scheduled.
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
|
||
# The deployment should become RUNNING with all 12 replicas.
|
||
wait_for_condition(check_apps_running, apps=["atomic_gang_app"], timeout=60)
|
||
|
||
# All 12 replicas should now serve traffic.
|
||
app_status = serve.status().applications["atomic_gang_app"]
|
||
dep_status = list(app_status.deployments.values())[0]
|
||
running = dep_status.replica_states.get("RUNNING", 0)
|
||
assert running == 12
|
||
|
||
serve.delete("atomic_gang_app")
|
||
serve.shutdown()
|
||
|
||
def test_pack_strategy(self, ray_cluster):
|
||
"""Verifies that PACK strategy places gang replicas on the same node."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment
|
||
def PackDeployment():
|
||
return os.environ.get(
|
||
"RAY_NODE_ID", ray.get_runtime_context().get_node_id()
|
||
)
|
||
|
||
# 1 gang with PACK strategy - all replicas should be on same node
|
||
app = PackDeployment.options(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(
|
||
gang_size=4,
|
||
gang_placement_strategy=GangPlacementStrategy.PACK,
|
||
),
|
||
).bind()
|
||
|
||
handle = serve.run(app, name="gang_pack_app")
|
||
wait_for_condition(check_apps_running, apps=["gang_pack_app"])
|
||
|
||
# Query multiple times to hit all replicas and collect node IDs.
|
||
# Intentionally handle-based: the assertion is that all replicas share
|
||
# a single node, and handle routing can only ever surface a subset of
|
||
# the nodes actually used. Locality-aware routing can therefore never
|
||
# inflate this count, so it cannot cause a false failure here.
|
||
node_ids = set()
|
||
for _ in range(40):
|
||
result = handle.remote().result()
|
||
node_ids.add(result)
|
||
|
||
# With PACK strategy, all 4 replicas should be on the same node
|
||
assert len(node_ids) == 1
|
||
|
||
serve.delete("gang_pack_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_scheduling_spread_strategy(self, ray_cluster):
|
||
"""Verifies that SPREAD strategy places gang replicas on different nodes."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment
|
||
def SpreadDeployment():
|
||
return os.environ.get(
|
||
"RAY_NODE_ID", ray.get_runtime_context().get_node_id()
|
||
)
|
||
|
||
# 1 gang with SPREAD strategy - replicas should be on different nodes
|
||
app = SpreadDeployment.options(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(
|
||
gang_size=2,
|
||
gang_placement_strategy=GangPlacementStrategy.SPREAD,
|
||
),
|
||
).bind()
|
||
|
||
serve.run(app, name="gang_spread_app")
|
||
wait_for_condition(check_apps_running, apps=["gang_spread_app"])
|
||
|
||
# With SPREAD strategy, 2 replicas should be on 2 different nodes.
|
||
dep_id = DeploymentID(name="SpreadDeployment", app_name="gang_spread_app")
|
||
running = _get_running_replicas(dep_id)
|
||
assert len(running) == 2
|
||
assert len(_get_node_ids_from_running(running)) == 2
|
||
|
||
serve.delete("gang_spread_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_context(self, ray_cluster):
|
||
"""Verifies GangContext is correctly populated in ReplicaContext."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment
|
||
class GangContextDeployment:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
app = GangContextDeployment.options(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
).bind()
|
||
|
||
serve.run(app, name="gang_context_app")
|
||
wait_for_condition(check_apps_running, apps=["gang_context_app"])
|
||
|
||
# Read gang context from controller replica state instead of handle
|
||
# routing (which may only hit local replicas under locality-aware
|
||
# routing). The controller stores the exact GangContext each replica
|
||
# reports from its own ReplicaContext, so this verifies the same values.
|
||
dep_id = DeploymentID(name="GangContextDeployment", app_name="gang_context_app")
|
||
running = _get_running_replicas(dep_id)
|
||
assert len(running) == 4
|
||
assert all(r.gang_context is not None for r in running)
|
||
|
||
# Group replicas by gang_id.
|
||
gangs = {}
|
||
for r in running:
|
||
gangs.setdefault(r.gang_context.gang_id, []).append(r)
|
||
|
||
assert len(gangs) == 2
|
||
|
||
for gang_id, members in gangs.items():
|
||
assert len(members) == 2
|
||
assert all(m.gang_context.world_size == 2 for m in members)
|
||
assert (
|
||
members[0].gang_context.member_replica_ids
|
||
== members[1].gang_context.member_replica_ids
|
||
)
|
||
|
||
expected_ids = sorted([m.replica_id.unique_id for m in members])
|
||
actual_ids = sorted(members[0].gang_context.member_replica_ids)
|
||
assert actual_ids == expected_ids
|
||
|
||
ranks = sorted([m.gang_context.rank for m in members])
|
||
assert ranks == [0, 1]
|
||
|
||
# Across gangs: gang_ids should be different.
|
||
gang_ids = list(gangs.keys())
|
||
assert gang_ids[0] != gang_ids[1]
|
||
|
||
# Across gangs: member_replica_ids should be different
|
||
gang_members_list = list(gangs.values())
|
||
assert sorted(
|
||
gang_members_list[0][0].gang_context.member_replica_ids
|
||
) != sorted(gang_members_list[1][0].gang_context.member_replica_ids)
|
||
|
||
serve.delete("gang_context_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_placement_groups_cleanup_on_deletion(self, ray_cluster):
|
||
"""Verifies serve.delete() removes reserved gang placement groups."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangDeleteCleanupDeployment:
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app_name = "gang_delete_cleanup_app"
|
||
deployment_name = "GangDeleteCleanupDeployment"
|
||
pg_name_prefix = f"{GANG_PG_NAME_PREFIX}{app_name}_{deployment_name}_"
|
||
|
||
serve.run(GangDeleteCleanupDeployment.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
wait_for_condition(
|
||
lambda: any(
|
||
name.startswith(pg_name_prefix)
|
||
for name in get_all_live_placement_group_names()
|
||
),
|
||
timeout=60,
|
||
)
|
||
|
||
serve.delete(app_name)
|
||
wait_for_condition(
|
||
lambda: not any(
|
||
name.startswith(pg_name_prefix)
|
||
for name in get_all_live_placement_group_names()
|
||
),
|
||
timeout=60,
|
||
)
|
||
serve.shutdown()
|
||
|
||
def test_multiple_gang_deployments_in_one_app(self, ray_cluster):
|
||
"""Verifies two gang deployments run together under one Serve app."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangA:
|
||
def __init__(self, gang_b):
|
||
self._gang_b = gang_b
|
||
|
||
def __call__(self):
|
||
return "a"
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangB:
|
||
def __call__(self):
|
||
return "b"
|
||
|
||
app_name = "multi_gang_app"
|
||
serve.run(GangA.bind(GangB.bind()), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
app_status = serve.status().applications[app_name]
|
||
assert app_status.deployments["GangA"].replica_states.get("RUNNING", 0) == 4
|
||
assert app_status.deployments["GangB"].replica_states.get("RUNNING", 0) == 4
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangResourceReservation:
|
||
@pytest.mark.parametrize(
|
||
"ray_actor_options, placement_group_bundles, gang_placement_strategy, "
|
||
"expected_bundles, expected_strategy, expect_same_node",
|
||
[
|
||
# Case 1: Only ray_actor_options — one flat bundle per replica, PACK
|
||
(
|
||
{"num_cpus": 0.25},
|
||
None,
|
||
"PACK",
|
||
[{"CPU": 0.25}, {"CPU": 0.25}],
|
||
"PACK",
|
||
True,
|
||
),
|
||
# Case 2: placement_group_bundles — flattened into the gang PG, PACK
|
||
(
|
||
{"num_cpus": 0},
|
||
[{"CPU": 0.25}] * 2,
|
||
"PACK",
|
||
[{"CPU": 0.25}] * 4,
|
||
"PACK",
|
||
True,
|
||
),
|
||
# Case 3: placement_group_bundles + SPREAD strategy
|
||
(
|
||
{"num_cpus": 0},
|
||
[{"CPU": 0.25}] * 2,
|
||
"SPREAD",
|
||
[{"CPU": 0.25}] * 4,
|
||
"SPREAD",
|
||
False,
|
||
),
|
||
],
|
||
)
|
||
def test_gang_resource_reservation(
|
||
self,
|
||
ray_cluster,
|
||
ray_actor_options,
|
||
placement_group_bundles,
|
||
gang_placement_strategy,
|
||
expected_bundles,
|
||
expected_strategy,
|
||
expect_same_node,
|
||
):
|
||
"""Verifies the gang PG has the correct bundles, strategy, and
|
||
that per-replica bundles are placed according to the strategy."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
deployment_kwargs = {
|
||
"num_replicas": 2,
|
||
"ray_actor_options": ray_actor_options,
|
||
"gang_scheduling_config": GangSchedulingConfig(
|
||
gang_size=2,
|
||
gang_placement_strategy=gang_placement_strategy,
|
||
),
|
||
}
|
||
if placement_group_bundles is not None:
|
||
deployment_kwargs["placement_group_bundles"] = placement_group_bundles
|
||
|
||
@serve.deployment(**deployment_kwargs)
|
||
class GangDeployment:
|
||
def get_pg_info(self):
|
||
pg = get_current_placement_group()
|
||
if pg is None:
|
||
return None
|
||
pg_table = placement_group_table(pg)
|
||
return {
|
||
"bundle_specs": pg.bundle_specs,
|
||
"strategy": pg_table["strategy"],
|
||
"bundles_to_node_id": pg_table["bundles_to_node_id"],
|
||
}
|
||
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app = GangDeployment.bind()
|
||
handle = serve.run(app, name="gang_reservation_app")
|
||
wait_for_condition(
|
||
check_apps_running,
|
||
apps=["gang_reservation_app"],
|
||
)
|
||
|
||
# Intentionally handle-based: each response is a self-contained
|
||
# per-replica invariant (bundle specs, strategy, per-replica bundle
|
||
# placement), so validating any sampled subset is sufficient. This
|
||
# never needs to enumerate all replicas, so locality-aware routing
|
||
# cannot cause a false failure.
|
||
for _ in range(20):
|
||
pg_info = handle.get_pg_info.remote().result()
|
||
assert pg_info is not None
|
||
assert pg_info["bundle_specs"] == expected_bundles
|
||
assert pg_info["strategy"] == expected_strategy
|
||
|
||
bundles_per_replica = (
|
||
len(placement_group_bundles) if placement_group_bundles else 1
|
||
)
|
||
gang_size = 2
|
||
|
||
for replica_idx in range(gang_size):
|
||
start = replica_idx * bundles_per_replica
|
||
replica_nodes = {
|
||
pg_info["bundles_to_node_id"][i]
|
||
for i in range(start, start + bundles_per_replica)
|
||
}
|
||
if expect_same_node:
|
||
assert len(replica_nodes) == 1
|
||
else:
|
||
assert len(replica_nodes) == bundles_per_replica
|
||
|
||
serve.delete("gang_reservation_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_label_selector(self, ray_cluster):
|
||
"""
|
||
Verifies that placement_group_bundle_label_selector steers gang bundles
|
||
onto the labeled node.
|
||
"""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1, labels={"accelerator": "tpu"})
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0},
|
||
placement_group_bundles=[{"CPU": 0.25}],
|
||
placement_group_bundle_label_selector=[{"accelerator": "tpu"}],
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class LabeledGangDeployment:
|
||
def get_pg_info(self):
|
||
pg = get_current_placement_group()
|
||
if pg is None:
|
||
return None
|
||
pg_table = placement_group_table(pg)
|
||
return {
|
||
"bundle_specs": pg.bundle_specs,
|
||
"bundles_to_node_id": pg_table["bundles_to_node_id"],
|
||
"node_labels": ray.get_runtime_context().get_node_labels(),
|
||
}
|
||
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app = LabeledGangDeployment.bind()
|
||
handle = serve.run(app, name="label_selector_app")
|
||
wait_for_condition(
|
||
check_apps_running,
|
||
apps=["label_selector_app"],
|
||
)
|
||
|
||
labeled_node_id = None
|
||
for node in ray.nodes():
|
||
if node["Labels"].get("accelerator") == "tpu":
|
||
labeled_node_id = node["NodeID"]
|
||
break
|
||
assert labeled_node_id is not None
|
||
|
||
# Intentionally handle-based: each response is a self-contained
|
||
# per-replica invariant (all bundles on the labeled node), so
|
||
# validating any sampled subset is sufficient and locality-aware
|
||
# routing cannot cause a false failure.
|
||
for _ in range(20):
|
||
pg_info = handle.get_pg_info.remote().result()
|
||
assert pg_info is not None
|
||
assert pg_info["bundle_specs"] == [{"CPU": 0.25}, {"CPU": 0.25}]
|
||
# Replica actor itself should be on the labeled node
|
||
assert pg_info["node_labels"].get("accelerator") == "tpu"
|
||
# All bundles in the gang PG should be on the labeled node
|
||
for node_id in pg_info["bundles_to_node_id"].values():
|
||
assert node_id == labeled_node_id
|
||
|
||
serve.delete("label_selector_app")
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangConstructorFailure:
|
||
"""Tests for gang scheduling with constructor failures."""
|
||
|
||
def test_consistent_constructor_failure(self, ray_shutdown):
|
||
"""Validates gang deployment where all replicas consistently fail their constructor."""
|
||
ray.init(num_cpus=1)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangConstructorFailure:
|
||
def __init__(self):
|
||
raise RuntimeError("Intentionally failing gang replica constructor")
|
||
|
||
async def __call__(self, request):
|
||
return "hi"
|
||
|
||
with pytest.raises(RuntimeError):
|
||
serve.run(GangConstructorFailure.bind())
|
||
|
||
client = serve.context._get_global_client()
|
||
deployment_dict = ray.get(client._controller._all_running_replicas.remote())
|
||
deployment_id = DeploymentID(name="GangConstructorFailure")
|
||
assert len(deployment_dict[deployment_id]) == 0
|
||
app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME]
|
||
assert app_status.status == "DEPLOY_FAILED"
|
||
assert (
|
||
app_status.deployments["GangConstructorFailure"].status == "DEPLOY_FAILED"
|
||
)
|
||
|
||
def test_partial_constructor_failure(self, ray_shutdown):
|
||
"""Validates gang deployment where one replica consistently fails."""
|
||
ray.init(num_cpus=1)
|
||
serve.start()
|
||
|
||
failed_replica_store = FailedGangReplicaStore.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangPartialConstructorFailure:
|
||
def __init__(self, store):
|
||
gang_id = serve.get_replica_context().gang_context.gang_id
|
||
is_first_fail = ray.get(store.mark_first_failing_gang.remote(gang_id))
|
||
if is_first_fail:
|
||
raise RuntimeError("Consistently throwing on same replica.")
|
||
should_fail = ray.get(store.mark_retry_failing_gang.remote(gang_id))
|
||
if should_fail:
|
||
raise RuntimeError("Keep failing the replica")
|
||
|
||
async def __call__(self, request):
|
||
return "hi"
|
||
|
||
serve._run(
|
||
GangPartialConstructorFailure.bind(failed_replica_store),
|
||
_blocking=False,
|
||
)
|
||
|
||
deployment_name = "GangPartialConstructorFailure"
|
||
|
||
def _one_gang_running_and_updating() -> bool:
|
||
app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME]
|
||
dep = app_status.deployments[deployment_name]
|
||
return (
|
||
dep.replica_states.get("RUNNING", 0) == 2 and dep.status == "UPDATING"
|
||
)
|
||
|
||
wait_for_condition(_one_gang_running_and_updating, timeout=30)
|
||
|
||
# Wait well past the failed-to-start threshold
|
||
# (max(num_replicas * 3, 6) = 12 for 4 replicas) to prove the
|
||
# deployment stays stuck in UPDATING.
|
||
def _enough_retries_and_still_stable() -> bool:
|
||
failed_gangs = ray.get(failed_replica_store.get_failed_gang_count.remote())
|
||
return failed_gangs >= 15 and _one_gang_running_and_updating()
|
||
|
||
wait_for_condition(_enough_retries_and_still_stable, timeout=90)
|
||
assert serve.status().applications[SERVE_DEFAULT_APP_NAME].status == "DEPLOYING"
|
||
|
||
def test_transient_constructor_failure(self, ray_shutdown):
|
||
"""Validates gang deployment where the first constructor call fails then succeeds."""
|
||
ray.init(num_cpus=1)
|
||
serve.start()
|
||
|
||
failed_replica_store = FailedGangReplicaStore.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangTransientConstructorFailure:
|
||
def __init__(self, store):
|
||
gang_id = serve.get_replica_context().gang_context.gang_id
|
||
is_first_fail = ray.get(store.mark_first_failing_gang.remote(gang_id))
|
||
if is_first_fail:
|
||
raise RuntimeError("Intentionally throw on first try.")
|
||
|
||
async def __call__(self, request):
|
||
return "hi"
|
||
|
||
serve.run(GangTransientConstructorFailure.bind(failed_replica_store))
|
||
|
||
client = serve.context._get_global_client()
|
||
deployment_id = DeploymentID(name="GangTransientConstructorFailure")
|
||
deployment_dict = ray.get(client._controller._all_running_replicas.remote())
|
||
assert len(deployment_dict[deployment_id]) == 4
|
||
app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME]
|
||
assert app_status.status == "RUNNING"
|
||
assert (
|
||
app_status.deployments["GangTransientConstructorFailure"].status
|
||
== "HEALTHY"
|
||
)
|
||
|
||
|
||
class TestGangFailureRecovery:
|
||
def test_startup_failure_stops_entire_gang(self, ray_shutdown):
|
||
"""Startup failure stops both replicas in the affected gang."""
|
||
ray.init(num_cpus=1)
|
||
serve.start()
|
||
failed_replica_store = FailedGangReplicaStore.remote()
|
||
recovery_signal = SignalActor.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class StartupFailureDeployment:
|
||
def __init__(self, failed_replica_store, recovery_signal):
|
||
gang_id = serve.get_replica_context().gang_context.gang_id
|
||
is_first_failure = ray.get(
|
||
failed_replica_store.mark_first_failing_gang.remote(gang_id)
|
||
)
|
||
if is_first_failure:
|
||
raise RuntimeError("Fail one startup to trigger gang cleanup.")
|
||
should_hold = ray.get(
|
||
failed_replica_store.mark_retry_failing_gang.remote(gang_id)
|
||
)
|
||
if should_hold:
|
||
# Hold failed replica retry until the intermediate state is asserted.
|
||
ray.get(recovery_signal.wait.remote())
|
||
|
||
def __call__(self):
|
||
ctx = serve.get_replica_context()
|
||
gc = ctx.gang_context
|
||
return {
|
||
"replica_id": ctx.replica_id.unique_id,
|
||
"gang_id": gc.gang_id,
|
||
}
|
||
|
||
app_name = "gang_startup_cleanup_app"
|
||
deployment_name = "StartupFailureDeployment"
|
||
handle = serve._run(
|
||
StartupFailureDeployment.bind(failed_replica_store, recovery_signal),
|
||
name=app_name,
|
||
_blocking=False,
|
||
)
|
||
|
||
# The unaffected gang should reach 2 RUNNING while the failed
|
||
# gang is being cleaned up and retried.
|
||
wait_for_condition(
|
||
lambda: (
|
||
serve.status()
|
||
.applications[app_name]
|
||
.deployments[deployment_name]
|
||
.replica_states.get("RUNNING", 0)
|
||
== 2
|
||
),
|
||
timeout=60,
|
||
)
|
||
|
||
# The 2 running replicas must belong to the SAME gang,
|
||
# proving no partial gang survived. Intentionally handle-based: this is
|
||
# a single-node cluster (ray.init(num_cpus=1)), so every replica is
|
||
# local to the caller and locality-aware routing still reaches all of
|
||
# them.
|
||
contexts = {}
|
||
for _ in range(50):
|
||
result = handle.remote().result()
|
||
contexts.setdefault(result["replica_id"], result)
|
||
if len(contexts) == 2:
|
||
break
|
||
assert len(contexts) == 2
|
||
assert len({ctx["gang_id"] for ctx in contexts.values()}) == 1
|
||
|
||
# Release constructor retry gate so the failed gang can recover.
|
||
ray.get(recovery_signal.send.remote())
|
||
|
||
# After retry, all 4 replicas should be RUNNING.
|
||
wait_for_condition(check_apps_running, apps=[app_name], timeout=60)
|
||
app_status = serve.status().applications[app_name]
|
||
dep_status = app_status.deployments[deployment_name]
|
||
assert dep_status.replica_states.get("RUNNING", 0) == 4
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
def test_health_failure_restarts_gang(self, ray_shutdown):
|
||
"""Single health check failure tears down and restarts the entire gang."""
|
||
ray.init(num_cpus=1)
|
||
serve.start()
|
||
target_replica_collector = Accumulator.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
health_check_period_s=1,
|
||
health_check_timeout_s=1,
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class HealthFailureDeployment:
|
||
def __call__(self):
|
||
ctx = serve.get_replica_context()
|
||
gc = ctx.gang_context
|
||
return {
|
||
"replica_id": ctx.replica_id.unique_id,
|
||
"gang_id": gc.gang_id,
|
||
}
|
||
|
||
def check_health(self):
|
||
targets = ray.get(target_replica_collector.get.remote())
|
||
if not targets:
|
||
return
|
||
target_id = targets[-1]
|
||
# Only 1 replica fails; its sibling stays healthy.
|
||
# The gang-aware cleanup must stop the sibling too.
|
||
ctx = serve.get_replica_context()
|
||
if ctx.replica_id.unique_id == target_id:
|
||
raise RuntimeError("Intentional health check failure.")
|
||
|
||
app_name = "gang_health_failure_app"
|
||
deployment_name = "HealthFailureDeployment"
|
||
handle = serve.run(HealthFailureDeployment.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name], timeout=60)
|
||
|
||
# Discover all 4 replica contexts. Intentionally handle-based: this is
|
||
# a single-node cluster (ray.init(num_cpus=1)), so every replica is
|
||
# local to the caller and locality-aware routing still reaches all of
|
||
# them (unlike the multi-node placement checks that read controller
|
||
# state).
|
||
contexts_by_replica = {}
|
||
for _ in range(120):
|
||
result = handle.remote().result()
|
||
contexts_by_replica.setdefault(result["replica_id"], result)
|
||
if len(contexts_by_replica) == 4:
|
||
break
|
||
assert len(contexts_by_replica) == 4
|
||
|
||
# Pick 1 replica to fail health checks.
|
||
target_ctx = next(iter(contexts_by_replica.values()))
|
||
target_gang_id = target_ctx["gang_id"]
|
||
|
||
target_gang_replica_ids = {
|
||
ctx["replica_id"]
|
||
for ctx in contexts_by_replica.values()
|
||
if ctx["gang_id"] == target_gang_id
|
||
}
|
||
unaffected_replica_ids = (
|
||
set(contexts_by_replica.keys()) - target_gang_replica_ids
|
||
)
|
||
assert len(target_gang_replica_ids) == 2
|
||
assert len(unaffected_replica_ids) == 2
|
||
|
||
# Trigger failure for only 1 replica in the target gang.
|
||
ray.get(target_replica_collector.add.remote(target_ctx["replica_id"]))
|
||
|
||
client = serve.context._get_global_client()
|
||
deployment_id = DeploymentID(name=deployment_name, app_name=app_name)
|
||
|
||
def check_target_gang_restarted():
|
||
replicas = ray.get(
|
||
client._controller._dump_replica_states_for_testing.remote(
|
||
deployment_id
|
||
)
|
||
)
|
||
running_replicas = replicas.get([ReplicaState.RUNNING])
|
||
running_ids = {r.replica_id.unique_id for r in running_replicas}
|
||
# Both old gang members must be gone (not just the one that
|
||
# failed), and the unaffected gang must be untouched.
|
||
return (
|
||
len(running_ids) == 4
|
||
and len(running_ids & target_gang_replica_ids) == 0
|
||
and len(running_ids & unaffected_replica_ids) == 2
|
||
)
|
||
|
||
wait_for_condition(check_target_gang_restarted, timeout=90)
|
||
wait_for_condition(check_apps_running, apps=[app_name], timeout=60)
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangChildSpawnPlacementGroup:
|
||
@ray.remote(num_cpus=0.1)
|
||
class ChildActor:
|
||
def get_pg(self):
|
||
return get_current_placement_group()
|
||
|
||
@ray.remote(num_cpus=0)
|
||
def child_task_get_pg():
|
||
return get_current_placement_group()
|
||
|
||
@pytest.mark.parametrize("child_type", ["actor", "task"])
|
||
def test_child_in_gang_pg(self, ray_cluster, child_type):
|
||
"""Spawn a child actor/task inside a gang replica and verify it shares the gang placement group."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
ChildActor = TestGangChildSpawnPlacementGroup.ChildActor
|
||
child_task_get_pg = TestGangChildSpawnPlacementGroup.child_task_get_pg
|
||
|
||
@serve.deployment(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
# Extra bundle per replica so the child actor has resources
|
||
# inside the gang PG (the first bundle is consumed by the replica).
|
||
placement_group_bundles=[{"CPU": 0.1}, {"CPU": 0.1}],
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangWithChild:
|
||
def test_child_in_pg(self):
|
||
parent_pg = get_current_placement_group()
|
||
if child_type == "actor":
|
||
child = ChildActor.remote()
|
||
child_pg = ray.get(child.get_pg.remote())
|
||
else:
|
||
child_pg = ray.get(child_task_get_pg.remote())
|
||
return {
|
||
"parent_pg_id": parent_pg.id.hex() if parent_pg else None,
|
||
"child_pg_id": child_pg.id.hex() if child_pg else None,
|
||
}
|
||
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app_name = "gang_child_app"
|
||
handle = serve.run(GangWithChild.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
for _ in range(20):
|
||
result = handle.test_child_in_pg.remote().result()
|
||
assert result["parent_pg_id"] is not None
|
||
assert result["child_pg_id"] is not None
|
||
assert result["child_pg_id"] == result["parent_pg_id"]
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
def test_child_actor_gang_pg_bundles_bounded(self, ray_cluster):
|
||
"""Gang replicas with placement_group_bundles: verify child actors are resource-bounded by the gang PG."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
ChildActor = TestGangChildSpawnPlacementGroup.ChildActor
|
||
|
||
@serve.deployment(
|
||
num_replicas=1,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
# Replica consumes the first bundle (0.1 CPU). Worker bundle (0.1
|
||
# CPU) fits exactly one ChildActor, so a second child is blocked.
|
||
placement_group_bundles=[{"CPU": 0.1}, {"CPU": 0.1}],
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=1),
|
||
)
|
||
class GangWithBundlesAndChild:
|
||
def test_second_worker_blocked(self):
|
||
"""The second child actor shouldn't fit in this replica's bundle slice."""
|
||
w1 = ChildActor.remote()
|
||
w2 = ChildActor.remote()
|
||
ready, _ = ray.wait([w2.get_pg.remote()], timeout=1)
|
||
ray.kill(w1)
|
||
ray.kill(w2)
|
||
return len(ready) == 0
|
||
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app_name = "gang_bundles_child_app"
|
||
handle = serve.run(GangWithBundlesAndChild.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
# Verify resource limits are enforced within the gang PG bundle slice.
|
||
for _ in range(4):
|
||
assert handle.test_second_worker_blocked.remote().result() is True
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
def test_child_actor_opt_out_gang_pg(self, ray_cluster):
|
||
"""Verify a child actor can opt out of the gang PG by passing placement_group=None."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
ChildActor = TestGangChildSpawnPlacementGroup.ChildActor
|
||
|
||
@serve.deployment(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class GangWithEscapedChild:
|
||
def get_child_outside_pg(self):
|
||
parent_pg = get_current_placement_group()
|
||
child = ChildActor.options(
|
||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||
placement_group=None, # Explicitly schedule outside the placement group
|
||
)
|
||
).remote()
|
||
child_pg = ray.get(child.get_pg.remote())
|
||
return {
|
||
"parent_pg_id": parent_pg.id.hex() if parent_pg else None,
|
||
"child_pg_id": child_pg.id.hex() if child_pg else None,
|
||
}
|
||
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app_name = "gang_escaped_child_app"
|
||
handle = serve.run(GangWithEscapedChild.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
for _ in range(20):
|
||
result = handle.get_child_outside_pg.remote().result()
|
||
assert result["parent_pg_id"] is not None
|
||
assert result["child_pg_id"] is None
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangControllerRecovery:
|
||
def test_gang_context_recovery(self, ray_cluster):
|
||
"""Verifies that the controller recovers all app and deployment states
|
||
after a crash, including gang_context for gang deployments and normal
|
||
replicas for non-gang deployments.
|
||
"""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class Gang1:
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
@serve.deployment(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class Gang2:
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
@serve.deployment(
|
||
num_replicas=2,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
)
|
||
class NoGang:
|
||
def __call__(self):
|
||
return "ok"
|
||
|
||
app_names = ["gang_app1", "gang_app2", "no_gang_app"]
|
||
serve.run(Gang1.bind(), name="gang_app1", route_prefix="/gang1")
|
||
serve.run(Gang2.bind(), name="gang_app2", route_prefix="/gang2")
|
||
serve.run(NoGang.bind(), name="no_gang_app", route_prefix="/no_gang")
|
||
wait_for_condition(check_apps_running, apps=app_names)
|
||
|
||
gang_deployment_ids = [
|
||
DeploymentID(name="Gang1", app_name="gang_app1"),
|
||
DeploymentID(name="Gang2", app_name="gang_app2"),
|
||
]
|
||
no_gang_deployment_id = DeploymentID(name="NoGang", app_name="no_gang_app")
|
||
controller = serve.context._get_global_client()._controller
|
||
|
||
# Record controller-side gang_context before crash
|
||
gang_ctx_before = {}
|
||
for dep_id in gang_deployment_ids:
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(dep_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
for r in running:
|
||
assert r.gang_context is not None
|
||
gang_ctx_before[r.replica_id.unique_id] = r.gang_context
|
||
|
||
# Record non-gang replica count
|
||
no_gang_replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(no_gang_deployment_id)
|
||
)
|
||
no_gang_count_before = len(no_gang_replicas.get([ReplicaState.RUNNING]))
|
||
assert no_gang_count_before == 2
|
||
|
||
# Kill the controller and wait for recovery of all apps
|
||
ray.kill(controller, no_restart=False)
|
||
wait_for_condition(check_apps_running, apps=app_names, timeout=60)
|
||
|
||
new_controller = serve.context._get_global_client()._controller
|
||
|
||
def all_states_recovered():
|
||
# Verify gang_context recovered for all gang deployments
|
||
for dep_id in gang_deployment_ids:
|
||
replicas = ray.get(
|
||
new_controller._dump_replica_states_for_testing.remote(dep_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
for r in running:
|
||
before = gang_ctx_before.get(r.replica_id.unique_id)
|
||
if r.gang_context is None or r.gang_context != before:
|
||
return False
|
||
|
||
# Verify non-gang deployment recovered
|
||
replicas = ray.get(
|
||
new_controller._dump_replica_states_for_testing.remote(
|
||
no_gang_deployment_id
|
||
)
|
||
)
|
||
if len(replicas.get([ReplicaState.RUNNING])) != no_gang_count_before:
|
||
return False
|
||
|
||
return True
|
||
|
||
wait_for_condition(all_states_recovered, timeout=60)
|
||
|
||
# Verify application and deployment statuses after recovery
|
||
status = serve.status()
|
||
for app_name in app_names:
|
||
app_status = status.applications[app_name]
|
||
assert app_status.status == "RUNNING"
|
||
for dep_name, dep_status in app_status.deployments.items():
|
||
assert dep_status.status == "HEALTHY"
|
||
|
||
for app_name in app_names:
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
@pytest.mark.parametrize("same_gang", [True, False])
|
||
def test_gang_replica_crash_during_controller_downtime(
|
||
self, ray_cluster, same_gang
|
||
):
|
||
"""When gang replicas crash while the controller is down, the controller
|
||
recovers and reschedules the affected gangs.
|
||
"""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
health_check_period_s=1,
|
||
)
|
||
class GangApp:
|
||
def __call__(self):
|
||
return os.getpid()
|
||
|
||
app_name = "gang_crash_app"
|
||
dep_id = DeploymentID(name="GangApp", app_name=app_name)
|
||
serve.run(GangApp.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
controller = serve.context._get_global_client()._controller
|
||
|
||
# Record initial replicas and group by gang.
|
||
replicas = ray.get(controller._dump_replica_states_for_testing.remote(dep_id))
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
assert len(running) == 4
|
||
|
||
gangs = {}
|
||
for r in running:
|
||
gangs.setdefault(r.gang_context.gang_id, []).append(r)
|
||
gang_ids = list(gangs.keys())
|
||
assert len(gang_ids) == 2
|
||
|
||
# Pick 2 victims
|
||
if same_gang:
|
||
victims = gangs[gang_ids[0]]
|
||
else:
|
||
victims = [gangs[gang_ids[0]][0], gangs[gang_ids[1]][0]]
|
||
|
||
victim_ids = {v.replica_id.unique_id for v in victims}
|
||
|
||
# Record the controller pid so we can confirm it actually restarts.
|
||
# ray.kill(..., no_restart=False) is asynchronous, so without this wait
|
||
# the checks below can read stale pre-crash state from the still-alive
|
||
# old controller (which still lists the victims as RUNNING).
|
||
original_controller_pid = ray.get(controller.get_pid.remote())
|
||
|
||
# Kill the controller, then kill the victims while it is down.
|
||
ray.kill(controller, no_restart=False)
|
||
for v in victims:
|
||
handle = ray.get_actor(v.replica_id.to_full_id_str(), namespace="serve")
|
||
ray.kill(handle, no_restart=True)
|
||
|
||
# Wait for the controller process to actually restart before checking
|
||
# recovery, otherwise we may observe the old controller's stale state.
|
||
def controller_restarted():
|
||
try:
|
||
pid = ray.get(controller.get_pid.remote(), timeout=5)
|
||
return pid != original_controller_pid
|
||
except Exception:
|
||
return False
|
||
|
||
wait_for_condition(controller_restarted, timeout=60)
|
||
wait_for_condition(check_apps_running, apps=[app_name], timeout=60)
|
||
|
||
new_controller = serve.context._get_global_client()._controller
|
||
|
||
# The affected gangs must be fully rescheduled: 4 RUNNING replicas, all
|
||
# with gang_context, and none of them the killed victims. Folding the
|
||
# victim check into the wait avoids racing the controller's reconcile.
|
||
def recovered_without_victims():
|
||
replicas = ray.get(
|
||
new_controller._dump_replica_states_for_testing.remote(dep_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
if len(running) != 4:
|
||
return False
|
||
running_ids = {r.replica_id.unique_id for r in running}
|
||
return victim_ids.isdisjoint(running_ids) and all(
|
||
r.gang_context is not None for r in running
|
||
)
|
||
|
||
wait_for_condition(recovered_without_victims, timeout=60)
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangNodeFailure:
|
||
@pytest.mark.parametrize(
|
||
"strategy", [GangPlacementStrategy.SPREAD, GangPlacementStrategy.PACK]
|
||
)
|
||
def test_worker_node_failure_restarts_gang(self, ray_cluster, strategy):
|
||
"""Killing a node restarts the affected gang with no request
|
||
downtime (surviving gang keeps serving) and no leaked PGs.
|
||
"""
|
||
cluster = ray_cluster
|
||
# Head and workers each get 1 CPU. Each PG (2 × 0.5 CPU = 1.0 CPU)
|
||
# fills exactly one node for PACK, and SPREAD distributes one bundle
|
||
# per node. At most 1 PG fits on the head, so at least 1 PG must
|
||
# land on a worker, giving the test a killable target. The extra
|
||
# nodes provide recovery capacity after one worker is removed.
|
||
cluster.add_node(num_cpus=1)
|
||
workers = [cluster.add_node(num_cpus=1) for _ in range(3)]
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
num_replicas = 4
|
||
gang_size = 2
|
||
|
||
@serve.deployment(
|
||
num_replicas=num_replicas,
|
||
ray_actor_options={"num_cpus": 0.5},
|
||
gang_scheduling_config=GangSchedulingConfig(
|
||
gang_size=gang_size,
|
||
gang_placement_strategy=strategy,
|
||
),
|
||
health_check_period_s=1,
|
||
)
|
||
class GangApp:
|
||
def __call__(self):
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
app_name = "node_kill_app"
|
||
dep_id = DeploymentID(name="GangApp", app_name=app_name)
|
||
handle = serve.run(GangApp.bind(), name=app_name)
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
controller = serve.context._get_global_client()._controller
|
||
replicas = ray.get(controller._dump_replica_states_for_testing.remote(dep_id))
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
assert len(running) == num_replicas
|
||
|
||
# Group replicas by gang
|
||
gangs = {}
|
||
for r in running:
|
||
gangs.setdefault(r.gang_context.gang_id, []).append(r)
|
||
|
||
# Pick a worker to kill that (a) hosts at least one gang member and
|
||
# (b) leaves at least one gang fully intact on the surviving nodes.
|
||
node_to_kill = None
|
||
affected_gangs = []
|
||
for worker in workers:
|
||
affected_gangs = [
|
||
gid
|
||
for gid, members in gangs.items()
|
||
if any(r.actor_node_id == worker.node_id for r in members)
|
||
]
|
||
surviving = [
|
||
gid
|
||
for gid, members in gangs.items()
|
||
if all(r.actor_node_id != worker.node_id for r in members)
|
||
]
|
||
if affected_gangs and surviving:
|
||
node_to_kill = worker
|
||
break
|
||
|
||
assert node_to_kill is not None
|
||
assert len(affected_gangs) > 0
|
||
|
||
# Continuously send requests in a background thread.
|
||
stop = threading.Event()
|
||
recovered = threading.Event()
|
||
errors_before_recovery = []
|
||
errors_after_recovery = []
|
||
successes = []
|
||
|
||
def send_requests():
|
||
while not stop.is_set():
|
||
try:
|
||
result = handle.remote().result()
|
||
successes.append(result)
|
||
except Exception as e:
|
||
if recovered.is_set():
|
||
errors_after_recovery.append(e)
|
||
else:
|
||
errors_before_recovery.append(e)
|
||
time.sleep(0.1)
|
||
|
||
sender = threading.Thread(target=send_requests, daemon=True)
|
||
sender.start()
|
||
time.sleep(1)
|
||
|
||
cluster.remove_node(node_to_kill)
|
||
|
||
expected_num_pgs = num_replicas // gang_size
|
||
|
||
def fully_recovered():
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(dep_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
if len(running) != num_replicas:
|
||
return False
|
||
for r in running:
|
||
if r.gang_context is None:
|
||
return False
|
||
# Verify PG count has converged: the old affected PG should be removed
|
||
# and the replacement PG should be created.
|
||
gang_pg_names = [
|
||
n
|
||
for n in get_all_live_placement_group_names()
|
||
if n.startswith(GANG_PG_NAME_PREFIX)
|
||
]
|
||
if len(gang_pg_names) != expected_num_pgs:
|
||
return False
|
||
return True
|
||
|
||
wait_for_condition(fully_recovered, timeout=60)
|
||
recovered.set()
|
||
|
||
# Wait for at least one post-recovery success
|
||
successes_at_recovery = len(successes)
|
||
wait_for_condition(
|
||
lambda: len(successes) > successes_at_recovery,
|
||
timeout=10,
|
||
)
|
||
stop.set()
|
||
sender.join(timeout=5)
|
||
|
||
# Requests may fail during the brief disruption window: the node
|
||
# is dead but the handle may still route to the dead replica actors
|
||
# until the controller detects the failure and restarts them.
|
||
# After full recovery, no errors should occur.
|
||
assert len(errors_after_recovery) == 0
|
||
assert len(successes) > 0
|
||
|
||
wait_for_condition(check_apps_running, apps=[app_name])
|
||
|
||
serve.delete(app_name)
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangScaling:
|
||
@pytest.mark.parametrize(
|
||
"initial_num_replicas, final_num_replicas",
|
||
[
|
||
(4, 2), # Manual downscale: serve deploy num_replicas = 4 -> 2
|
||
(8, 4), # Downscaling
|
||
(4, 8), # Upscaling
|
||
],
|
||
)
|
||
def test_scale_gang_boundary(
|
||
self, ray_cluster, initial_num_replicas, final_num_replicas
|
||
):
|
||
"""Validates that scaling preserves complete gangs."""
|
||
GANG_SIZE = 2
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
name="D",
|
||
num_replicas=initial_num_replicas,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
)
|
||
class D:
|
||
def __call__(self):
|
||
ctx = ray.serve.context._get_internal_replica_context()
|
||
gc = ctx.gang_context
|
||
return {"pid": os.getpid(), "gang_id": gc.gang_id if gc else None}
|
||
|
||
D = D.options(_internal=True, version="v1")
|
||
handle = serve.run(D.bind(), name="app")
|
||
wait_for_condition(check_apps_running, apps=["app"])
|
||
|
||
initial_num_gangs = initial_num_replicas // GANG_SIZE
|
||
deployment_id = DeploymentID(name="D", app_name="app")
|
||
|
||
initial_running = _get_running_replicas(deployment_id)
|
||
assert len(initial_running) == initial_num_replicas
|
||
initial_gang_ids = _get_gang_ids_from_running(initial_running)
|
||
assert len(initial_gang_ids) == initial_num_gangs
|
||
|
||
# Monitor requests during scaling to ensure zero downtime
|
||
errors, successes = [], []
|
||
stop_event = threading.Event()
|
||
|
||
def send_requests():
|
||
while not stop_event.is_set():
|
||
try:
|
||
handle.remote().result(timeout_s=10)
|
||
successes.append(True)
|
||
except Exception as e:
|
||
errors.append(str(e))
|
||
time.sleep(0.1)
|
||
|
||
t = threading.Thread(target=send_requests, daemon=True)
|
||
t.start()
|
||
|
||
# Scale to the final replica count.
|
||
handle = serve.run(
|
||
D.options(num_replicas=final_num_replicas).bind(), name="app"
|
||
)
|
||
wait_for_condition(check_apps_running, apps=["app"])
|
||
|
||
deployment = list(serve.status().applications["app"].deployments.values())[0]
|
||
assert deployment.replica_states.get("RUNNING", 0) == final_num_replicas
|
||
|
||
stop_event.set()
|
||
t.join(timeout=5)
|
||
|
||
# Scaling should be zero-downtime: no requests should fail.
|
||
assert len(errors) == 0
|
||
assert len(successes) > 0
|
||
|
||
final_num_gangs = final_num_replicas // GANG_SIZE
|
||
|
||
final_running = _get_running_replicas(deployment_id)
|
||
assert len(final_running) == final_num_replicas
|
||
final_gang_ids = _get_gang_ids_from_running(final_running)
|
||
assert len(final_gang_ids) == final_num_gangs
|
||
|
||
smaller, larger = sorted([initial_gang_ids, final_gang_ids], key=len)
|
||
assert smaller.issubset(larger)
|
||
|
||
serve.delete("app")
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangRollingUpdate:
|
||
def test_rolling_update(self, ray_cluster):
|
||
"""Verifies that rolling update replaces complete gangs atomically.
|
||
|
||
During the update, RUNNING replicas must always form complete gangs.
|
||
"""
|
||
GANG_SIZE = 2
|
||
NUM_REPLICAS = 4
|
||
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
name="D",
|
||
num_replicas=NUM_REPLICAS,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
)
|
||
class V1:
|
||
def __call__(self):
|
||
return "v1"
|
||
|
||
handle = serve.run(V1.bind(), name="app")
|
||
wait_for_condition(check_apps_running, apps=["app"])
|
||
assert handle.remote().result() == "v1"
|
||
|
||
client = _get_global_client()
|
||
controller = client._controller
|
||
deployment_id = DeploymentID(name="D", app_name="app")
|
||
|
||
# Collect initial gang_ids.
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(deployment_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
assert len(running) == NUM_REPLICAS
|
||
initial_gang_ids = {r.gang_context.gang_id for r in running}
|
||
assert len(initial_gang_ids) == NUM_REPLICAS // GANG_SIZE
|
||
|
||
# Gate V2 startup behind a signal so we can deterministically
|
||
# observe mixed old/new gang state during the rolling update.
|
||
signal = SignalActor.remote()
|
||
|
||
# New code version triggers requires_actor_restart -> rolling update
|
||
@serve.deployment(
|
||
name="D",
|
||
num_replicas=NUM_REPLICAS,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
)
|
||
class V2:
|
||
def __init__(self):
|
||
ray.get(signal.wait.remote())
|
||
|
||
def __call__(self):
|
||
return "v2"
|
||
|
||
# Issue the update without blocking so we can poll
|
||
# intermediate controller state from the main thread.
|
||
serve._run(V2.bind(), name="app", _blocking=False)
|
||
|
||
# Wait until we observe mixed state: at least one old gang still
|
||
# RUNNING and at least one new gang in STARTING (blocked on signal).
|
||
def mixed_state_observed():
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(deployment_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
starting = replicas.get([ReplicaState.STARTING])
|
||
|
||
running_gang_ids = {
|
||
r.gang_context.gang_id for r in running if r.gang_context is not None
|
||
}
|
||
starting_gang_ids = {
|
||
r.gang_context.gang_id for r in starting if r.gang_context is not None
|
||
}
|
||
|
||
has_old_running = bool(running_gang_ids & initial_gang_ids)
|
||
has_new_starting = bool(starting_gang_ids - initial_gang_ids)
|
||
|
||
# While old gangs are still running, they must be complete.
|
||
gang_counts: dict = {}
|
||
for r in running:
|
||
if r.gang_context is not None:
|
||
gid = r.gang_context.gang_id
|
||
gang_counts[gid] = gang_counts.get(gid, 0) + 1
|
||
for gid, count in gang_counts.items():
|
||
if gid in initial_gang_ids:
|
||
assert count == GANG_SIZE
|
||
|
||
return has_old_running and has_new_starting
|
||
|
||
wait_for_condition(mixed_state_observed, timeout=30)
|
||
|
||
# Unblock V2 constructors and wait for the update to finish.
|
||
signal.send.remote()
|
||
|
||
def update_complete():
|
||
replicas = ray.get(
|
||
controller._dump_replica_states_for_testing.remote(deployment_id)
|
||
)
|
||
running = replicas.get([ReplicaState.RUNNING])
|
||
if len(running) != NUM_REPLICAS:
|
||
return False
|
||
current_gang_ids = {
|
||
r.gang_context.gang_id for r in running if r.gang_context is not None
|
||
}
|
||
return current_gang_ids and not (current_gang_ids & initial_gang_ids)
|
||
|
||
wait_for_condition(update_complete, timeout=60)
|
||
|
||
# Confirm all replicas serve the new version.
|
||
for _ in range(20):
|
||
assert handle.remote().result() == "v2"
|
||
|
||
serve.delete("app")
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangAutoscaling:
|
||
def test_gang_autoscaling(self, ray_cluster):
|
||
"""Verifies that autoscaling with gang scheduling scales in complete gangs."""
|
||
GANG_SIZE = 2
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
signal = SignalActor.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas="auto",
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
autoscaling_config={
|
||
"min_replicas": 2,
|
||
"max_replicas": 8,
|
||
# Lower delays/windows so the test observes scaling within seconds
|
||
"upscale_delay_s": 0.1,
|
||
"downscale_delay_s": 0.1,
|
||
"look_back_period_s": 0.1,
|
||
"target_ongoing_requests": 1,
|
||
},
|
||
max_ongoing_requests=20,
|
||
)
|
||
class GangAutoscale:
|
||
async def __call__(self):
|
||
await signal.wait.remote()
|
||
return os.getpid()
|
||
|
||
handle = serve.run(GangAutoscale.bind(), name="gang_autoscale_app")
|
||
wait_for_condition(check_apps_running, apps=["gang_autoscale_app"])
|
||
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="GangAutoscale",
|
||
target=2,
|
||
app_name="gang_autoscale_app",
|
||
use_controller=True,
|
||
)
|
||
|
||
# Send enough requests to trigger upscaling
|
||
results = [handle.remote() for _ in range(20)]
|
||
|
||
# Wait for scale-up to 8 replicas (4 complete gangs).
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="GangAutoscale",
|
||
target=8,
|
||
app_name="gang_autoscale_app",
|
||
timeout=60,
|
||
use_controller=True,
|
||
)
|
||
|
||
# Replica count should always be a multiple of gang_size
|
||
deployment = (
|
||
serve.status()
|
||
.applications["gang_autoscale_app"]
|
||
.deployments["GangAutoscale"]
|
||
)
|
||
running = deployment.replica_states.get("RUNNING")
|
||
assert running % GANG_SIZE == 0
|
||
|
||
# Release all requests to allow traffic to drain
|
||
signal.send.remote()
|
||
for res in results:
|
||
res.result()
|
||
|
||
# As the queue is drained, we should scale back down
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="GangAutoscale",
|
||
target=2,
|
||
app_name="gang_autoscale_app",
|
||
timeout=60,
|
||
use_controller=True,
|
||
)
|
||
|
||
deployment = (
|
||
serve.status()
|
||
.applications["gang_autoscale_app"]
|
||
.deployments["GangAutoscale"]
|
||
)
|
||
running = deployment.replica_states.get("RUNNING")
|
||
assert running % GANG_SIZE == 0
|
||
|
||
serve.delete("gang_autoscale_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_autoscaling_unaligned_upscale(self, ray_cluster):
|
||
GANG_SIZE = 3
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
signal = SignalActor.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas="auto",
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
autoscaling_config={
|
||
"min_replicas": 3,
|
||
"max_replicas": 9,
|
||
"metrics_interval_s": 0.5,
|
||
"upscale_delay_s": 0.1,
|
||
"downscale_delay_s": 0.1,
|
||
"look_back_period_s": 1,
|
||
"target_ongoing_requests": 2,
|
||
},
|
||
max_ongoing_requests=50,
|
||
)
|
||
class UnalignedUpscale:
|
||
async def __call__(self):
|
||
await signal.wait.remote()
|
||
return os.getpid()
|
||
|
||
handle = serve.run(UnalignedUpscale.bind(), name="unaligned_upscale_app")
|
||
wait_for_condition(check_apps_running, apps=["unaligned_upscale_app"])
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="UnalignedUpscale",
|
||
target=3,
|
||
app_name="unaligned_upscale_app",
|
||
use_controller=True,
|
||
)
|
||
|
||
# Send 9 blocking requests. With target_ongoing_requests=2:
|
||
# desired = ceil(9/2) = 5 (unaligned).
|
||
# Gang policy rounds up: ceil(5/3)*3 = 6.
|
||
results = [handle.remote() for _ in range(9)]
|
||
|
||
def upscaled_and_aligned():
|
||
deployment = (
|
||
serve.status()
|
||
.applications["unaligned_upscale_app"]
|
||
.deployments["UnalignedUpscale"]
|
||
)
|
||
running = deployment.replica_states.get("RUNNING", 0)
|
||
assert running == 6
|
||
return True
|
||
|
||
wait_for_condition(upscaled_and_aligned, timeout=60)
|
||
|
||
# Release all requests so the queue drains.
|
||
signal.send.remote()
|
||
for res in results:
|
||
res.result()
|
||
|
||
# The autoscaler should scale back down to min_replicas=3.
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="UnalignedUpscale",
|
||
target=3,
|
||
app_name="unaligned_upscale_app",
|
||
timeout=60,
|
||
use_controller=True,
|
||
)
|
||
|
||
serve.delete("unaligned_upscale_app")
|
||
serve.shutdown()
|
||
|
||
def test_gang_autoscaling_unaligned_downscale(self, ray_cluster):
|
||
GANG_SIZE = 3
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=2)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
signal = SignalActor.remote()
|
||
|
||
@serve.deployment(
|
||
num_replicas="auto",
|
||
ray_actor_options={"num_cpus": 0.1},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=GANG_SIZE),
|
||
autoscaling_config={
|
||
"min_replicas": 6,
|
||
"max_replicas": 9,
|
||
"initial_replicas": 9,
|
||
"metrics_interval_s": 0.5,
|
||
"upscale_delay_s": 5,
|
||
# Must be long enough for all 9 gang-scheduled replicas to
|
||
# start before the autoscaler can trigger a downscale.
|
||
"downscale_delay_s": 20,
|
||
"look_back_period_s": 1,
|
||
"target_ongoing_requests": 2,
|
||
},
|
||
max_ongoing_requests=50,
|
||
)
|
||
class UnalignedDownscale:
|
||
async def __call__(self):
|
||
await signal.wait.remote()
|
||
return os.getpid()
|
||
|
||
handle = serve.run(UnalignedDownscale.bind(), name="unaligned_downscale_app")
|
||
wait_for_condition(check_apps_running, apps=["unaligned_downscale_app"])
|
||
wait_for_condition(
|
||
check_num_replicas_eq,
|
||
name="UnalignedDownscale",
|
||
target=9,
|
||
app_name="unaligned_downscale_app",
|
||
use_controller=True,
|
||
timeout=60,
|
||
)
|
||
|
||
# Send 10 blocking requests. With target_ongoing_requests=2:
|
||
# desired = ceil(10/2) = 5 (unaligned).
|
||
# Gang policy rounds up: ceil(5/3)*3 = 6.
|
||
results = [handle.remote() for _ in range(10)]
|
||
|
||
# Wait for downscale from 9 to 6.
|
||
def downscaled_and_aligned():
|
||
deployment = (
|
||
serve.status()
|
||
.applications["unaligned_downscale_app"]
|
||
.deployments["UnalignedDownscale"]
|
||
)
|
||
running = deployment.replica_states.get("RUNNING", 0)
|
||
assert running == 6
|
||
return True
|
||
|
||
wait_for_condition(downscaled_and_aligned, timeout=60)
|
||
|
||
# Release all requests so the queue drains.
|
||
signal.send.remote()
|
||
for res in results:
|
||
res.result()
|
||
|
||
serve.delete("unaligned_downscale_app")
|
||
serve.shutdown()
|
||
|
||
|
||
class TestGangMigration:
|
||
def test_gang_migration(self, ray_cluster):
|
||
"""Verifies that when a node drains, entire gangs migrate together."""
|
||
cluster = ray_cluster
|
||
cluster.add_node(num_cpus=1)
|
||
node_to_drain = cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
ray.init(address=cluster.address)
|
||
serve.start()
|
||
|
||
@serve.deployment(
|
||
name="D",
|
||
num_replicas=4,
|
||
ray_actor_options={"num_cpus": 0.25},
|
||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||
)
|
||
class D:
|
||
def __call__(self):
|
||
ctx = ray.serve.context._get_internal_replica_context()
|
||
gc = ctx.gang_context
|
||
return {
|
||
"pid": os.getpid(),
|
||
"gang_id": gc.gang_id if gc else None,
|
||
"node_id": ray.get_runtime_context().get_node_id(),
|
||
}
|
||
|
||
D = D.options(_internal=True, version="v1")
|
||
serve.run(D.bind(), name="app")
|
||
wait_for_condition(check_apps_running, apps=["app"])
|
||
|
||
deployment_id = DeploymentID(name="D", app_name="app")
|
||
running = _get_running_replicas(deployment_id)
|
||
assert len(running) == 4
|
||
assert len(_get_gang_ids_from_running(running)) == 2
|
||
|
||
# Add another node for replicas to migrate to, then drain a node
|
||
cluster.add_node(num_cpus=1)
|
||
cluster.wait_for_nodes()
|
||
cluster.remove_node(node_to_drain)
|
||
wait_for_condition(check_apps_running, apps=["app"], timeout=120)
|
||
|
||
deployment = list(serve.status().applications["app"].deployments.values())[0]
|
||
assert deployment.replica_states.get("RUNNING", 0) == 4
|
||
|
||
def check_complete_gangs():
|
||
running = _get_running_replicas(deployment_id)
|
||
assert len(running) == 4
|
||
gang_ids = {
|
||
r.gang_context.gang_id for r in running if r.gang_context is not None
|
||
}
|
||
assert len(gang_ids) == 2
|
||
return True
|
||
|
||
wait_for_condition(check_complete_gangs, timeout=60)
|
||
|
||
serve.delete("app")
|
||
serve.shutdown()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(pytest.main(["-v", "-s", __file__]))
|