167 lines
5.9 KiB
Python
167 lines
5.9 KiB
Python
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.util.placement_group import (
|
|
NODE_ID_LABEL_KEY,
|
|
placement_group,
|
|
placement_group_table,
|
|
)
|
|
|
|
NODE_ID_LABEL = NODE_ID_LABEL_KEY
|
|
RACK_LABEL = "ray.io/gpu-domain"
|
|
RACK_ONE = "rack-1"
|
|
RACK_TWO = "rack-2"
|
|
rack1_labels = {RACK_LABEL: RACK_ONE}
|
|
rack2_labels = {RACK_LABEL: RACK_TWO}
|
|
|
|
|
|
def assert_pg_nodes_label_value(cluster_nodes, pg, label, value):
|
|
node_id_to_labels = {node["NodeID"]: node["Labels"] for node in cluster_nodes}
|
|
for node_id in placement_group_table(pg)["bundles_to_node_id"].values():
|
|
assert node_id_to_labels[node_id].get(label) == value
|
|
|
|
|
|
def test_topology_strategy_feasible_after_rack_kill(ray_start_cluster):
|
|
"""Verify topology-aware rescheduling after total rack failure.
|
|
|
|
Creates a PG on rack 1 (the only available rack at the time). After
|
|
removing one rack 1 node, the PG enters RESCHEDULING but stays pinned to
|
|
rack 1, so it remains infeasible even though rack 2 has capacity. Once
|
|
all rack 1 nodes are removed (total failure), the topology assignment
|
|
is cleared and the PG reschedules onto rack 2.
|
|
"""
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=0)
|
|
ray.init(address=cluster.address)
|
|
|
|
rack1_nodes = [cluster.add_node(num_cpus=1, labels=rack1_labels) for _ in range(4)]
|
|
|
|
bundles = [{"CPU": 1}] * 4
|
|
|
|
pg = placement_group(
|
|
bundles=bundles,
|
|
topology_strategy={RACK_LABEL: "STRICT_PACK"},
|
|
)
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
assert_pg_nodes_label_value(ray.nodes(), pg, RACK_LABEL, RACK_ONE)
|
|
|
|
# Bring rack 2 online; PG should still be pinned to rack 1.
|
|
for _ in range(4):
|
|
cluster.add_node(num_cpus=1, labels=rack2_labels)
|
|
|
|
# Drop one rack 1 node -> partial failure -> RESCHEDULING + infeasible.
|
|
cluster.remove_node(rack1_nodes[0])
|
|
with pytest.raises(ray.exceptions.GetTimeoutError):
|
|
ray.get(pg.ready(), timeout=5)
|
|
assert placement_group_table(pg)["state"] == "RESCHEDULING"
|
|
|
|
# Total rack 1 failure -> clears assignment -> reschedules onto rack 2.
|
|
for node in rack1_nodes[1:]:
|
|
cluster.remove_node(node)
|
|
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
assert_pg_nodes_label_value(ray.nodes(), pg, RACK_LABEL, RACK_TWO)
|
|
|
|
|
|
def test_topology_strategy_strict_pack(ray_start_cluster):
|
|
"""Testing STRICT_PACK on the node level and STRICT_PACK on the rack level.
|
|
|
|
Provides two candidate 4-CPU nodes on rack 1 so STRICT_PACK at the node
|
|
level has a real choice to make; asserts that all bundles end up on a
|
|
single node (validating the node-level packing) and on rack 1
|
|
(validating the rack-level packing).
|
|
"""
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=0)
|
|
ray.init(address=cluster.address)
|
|
|
|
# Two candidate nodes — STRICT_PACK at the node level must pick one.
|
|
cluster.add_node(num_cpus=4, labels=rack1_labels)
|
|
cluster.add_node(num_cpus=4, labels=rack1_labels)
|
|
|
|
bundles = [{"CPU": 1}] * 4
|
|
|
|
pg = placement_group(
|
|
bundles=bundles,
|
|
topology_strategy={NODE_ID_LABEL: "STRICT_PACK", RACK_LABEL: "STRICT_PACK"},
|
|
)
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
assert_pg_nodes_label_value(ray.nodes(), pg, RACK_LABEL, RACK_ONE)
|
|
|
|
# Verify STRICT_PACK at the node level: all bundles on the same node.
|
|
bundle_nodes = set(placement_group_table(pg)["bundles_to_node_id"].values())
|
|
assert len(bundle_nodes) == 1
|
|
|
|
|
|
def test_topology_strategy_strict_spread(ray_start_cluster):
|
|
"""Testing STRICT_SPREAD on the node level and STRICT_PACK on the rack level.
|
|
|
|
Provides six rack-1 nodes for four bundles so STRICT_SPREAD at the node
|
|
level has slack to choose from; asserts that each bundle lands on a
|
|
distinct node (validating node-level spreading) and that all bundles
|
|
share rack 1 (validating rack-level packing).
|
|
"""
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=0)
|
|
ray.init(address=cluster.address)
|
|
|
|
# Six rack-1 nodes for four bundles — STRICT_SPREAD has slack.
|
|
for _ in range(6):
|
|
cluster.add_node(num_cpus=2, labels=rack1_labels)
|
|
|
|
bundles = [{"CPU": 1}] * 4
|
|
|
|
pg = placement_group(
|
|
bundles=bundles,
|
|
topology_strategy={NODE_ID_LABEL: "STRICT_SPREAD", RACK_LABEL: "STRICT_PACK"},
|
|
)
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
assert_pg_nodes_label_value(ray.nodes(), pg, RACK_LABEL, RACK_ONE)
|
|
|
|
# Verify STRICT_SPREAD at the node level: each bundle on a distinct node.
|
|
bundle_nodes = list(placement_group_table(pg)["bundles_to_node_id"].values())
|
|
assert len(bundle_nodes) == len(set(bundle_nodes)) == 4
|
|
|
|
|
|
def test_topology_strategy_reschedule_on_node_failure(ray_start_cluster):
|
|
"""Verify rescheduling stays within the same rack on partial failure.
|
|
|
|
Provides 6 rack-1 nodes for a 4-bundle PG. Kills 2 nodes holding bundles;
|
|
asserts the PG re-creates with all bundles still on rack-1 rather than
|
|
leaking onto a different domain.
|
|
"""
|
|
cluster = ray_start_cluster
|
|
cluster.add_node(num_cpus=0)
|
|
ray.init(address=cluster.address)
|
|
|
|
rack_nodes = [cluster.add_node(num_cpus=1, labels=rack1_labels) for _ in range(6)]
|
|
|
|
bundles = [{"CPU": 1}] * 4
|
|
pg = placement_group(
|
|
bundles=bundles,
|
|
topology_strategy={NODE_ID_LABEL: "PACK", RACK_LABEL: "STRICT_PACK"},
|
|
)
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
|
|
cluster.remove_node(rack_nodes[0])
|
|
cluster.remove_node(rack_nodes[1])
|
|
|
|
ray.get(pg.ready(), timeout=30)
|
|
assert placement_group_table(pg)["state"] == "CREATED"
|
|
assert_pg_nodes_label_value(ray.nodes(), pg, RACK_LABEL, RACK_ONE)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if os.environ.get("PARALLEL_CI"):
|
|
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
|
|
else:
|
|
sys.exit(pytest.main(["-sv", __file__]))
|