import os import sys import time from typing import List import pytest import ray import ray.cluster_utils import ray.experimental.internal_kv as internal_kv from ray import ObjectRef from ray._common.test_utils import ( run_string_as_driver, wait_for_condition, ) from ray._private.ray_constants import ( DEBUG_AUTOSCALING_ERROR, DEBUG_AUTOSCALING_STATUS, ) from ray._private.test_utils import ( generate_system_config_map, is_placement_group_removed, kill_actor_and_wait_for_failure, reset_autoscaler_v2_enabled_cache, ) from ray.autoscaler._private.commands import debug_status from ray.autoscaler._private.constants import AUTOSCALER_UPDATE_INTERVAL_S from ray.exceptions import RaySystemError from ray.util.placement_group import placement_group, remove_placement_group from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy def _get_status_section( status_output: str, headers: List[str], next_headers: List[str] ) -> str: lines = status_output.splitlines() start_index = None header_set = set(headers) next_header_set = set(next_headers) for index, line in enumerate(lines): if line.strip() in header_set: start_index = index + 1 break if start_index is None: return "" section_lines = [] for line in lines[start_index:]: if line.strip() in next_header_set: break if not section_lines and not line.strip(): continue section_lines.append(line.rstrip()) return "\n".join(section_lines).strip() def get_ray_status_output(address): gcs_client = ray._raylet.GcsClient(address=address) internal_kv._initialize_internal_kv(gcs_client) status = internal_kv._internal_kv_get(DEBUG_AUTOSCALING_STATUS) error = internal_kv._internal_kv_get(DEBUG_AUTOSCALING_ERROR) status_output = debug_status(status, error, address=address) print(status_output) return { "demand": _get_status_section( status_output, headers=["Demands:", "Pending Demands:"], next_headers=[], ), "usage": _get_status_section( status_output, headers=["Usage:", "Total Usage:"], next_headers=["Demands:", "Pending Demands:"], ), } @pytest.mark.parametrize( "ray_start_cluster_head_with_external_redis", [ generate_system_config_map( health_check_initial_delay_ms=0, health_check_failure_threshold=10, ) ], indirect=True, ) def test_create_placement_group_during_gcs_server_restart( ray_start_cluster_head_with_external_redis, ): cluster = ray_start_cluster_head_with_external_redis cluster.add_node(num_cpus=200) cluster.wait_for_nodes() # Create placement groups during gcs server restart. placement_groups = [] for i in range(0, 100): placement_group = ray.util.placement_group([{"CPU": 1}, {"CPU": 1}]) placement_groups.append(placement_group) cluster.head_node.kill_gcs_server() cluster.head_node.start_gcs_server() for i in range(0, 100): ray.get(placement_groups[i].ready()) @pytest.mark.parametrize( "ray_start_cluster_head_with_external_redis", [ generate_system_config_map( health_check_initial_delay_ms=0, health_check_failure_threshold=10, ) ], indirect=True, ) def test_placement_group_wait_api(ray_start_cluster_head_with_external_redis): cluster = ray_start_cluster_head_with_external_redis cluster.add_node(num_cpus=2) cluster.add_node(num_cpus=2) cluster.wait_for_nodes() # Create placement group 1 successfully. placement_group1 = ray.util.placement_group([{"CPU": 1}, {"CPU": 1}]) assert placement_group1.wait(10) # Restart gcs server. cluster.head_node.kill_gcs_server() cluster.head_node.start_gcs_server() # Create placement group 2 successfully. placement_group2 = ray.util.placement_group([{"CPU": 1}, {"CPU": 1}]) assert placement_group2.wait(10) # Remove placement group 1. ray.util.remove_placement_group(placement_group1) # Wait for placement group 1 after it is removed. with pytest.raises(Exception): placement_group1.wait(10) def test_placement_group_wait_api_timeout(shutdown_only): """Make sure the wait API timeout works https://github.com/ray-project/ray/issues/27287 """ ray.init(num_cpus=1) pg = ray.util.placement_group(bundles=[{"CPU": 2}]) start = time.time() assert not pg.wait(5) assert 5 <= time.time() - start def test_schedule_placement_groups_at_the_same_time(shutdown_only): ray.init(num_cpus=4) pgs = [placement_group([{"CPU": 2}]) for _ in range(6)] wait_pgs = {pg.ready(): pg for pg in pgs} def is_all_placement_group_removed(): ready, _ = ray.wait(list(wait_pgs.keys()), timeout=0.5) if ready: ready_pg = wait_pgs[ready[0]] remove_placement_group(ready_pg) del wait_pgs[ready[0]] if len(wait_pgs) == 0: return True return False wait_for_condition(is_all_placement_group_removed) @pytest.mark.parametrize( "ray_start_cluster", [ { "include_dashboard": True, } ], indirect=True, ) def test_detached_placement_group(ray_start_cluster): cluster = ray_start_cluster for _ in range(2): cluster.add_node(num_cpus=3) cluster.wait_for_nodes() info = ray.init(address=cluster.address) # Make sure detached placement group will alive when job dead. driver_code = f""" import ray from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy ray.init(address="{info["address"]}") pg = ray.util.placement_group( [{{"CPU": 1}} for _ in range(2)], strategy="STRICT_SPREAD", lifetime="detached") ray.get(pg.ready()) @ray.remote(num_cpus=1) class Actor: def ready(self): return True for bundle_index in range(2): actor = Actor.options(lifetime="detached", scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg, placement_group_bundle_index=bundle_index)).remote() ray.get(actor.ready.remote()) ray.shutdown() """ run_string_as_driver(driver_code) # Wait until the driver is reported as dead by GCS. def is_job_done(): jobs = ray._private.state.jobs() for job in jobs: if job["IsDead"]: return True return False def assert_alive_num_pg(expected_num_pg): alive_num_pg = 0 for _, placement_group_info in ray.util.placement_group_table().items(): if placement_group_info["state"] == "CREATED": alive_num_pg += 1 return alive_num_pg == expected_num_pg def assert_alive_num_actor(expected_num_actor): alive_num_actor = 0 for actor_info in ray.util.state.list_actors(): if actor_info.state == "ALIVE": alive_num_actor += 1 return alive_num_actor == expected_num_actor wait_for_condition(is_job_done) assert assert_alive_num_pg(1) assert assert_alive_num_actor(2) # Make sure detached placement group will alive when its creator which # is detached actor dead. # Test actors first. @ray.remote(num_cpus=1) class NestedActor: def ready(self): return True @ray.remote(num_cpus=1) class Actor: def __init__(self): self.actors = [] def ready(self): return True def schedule_nested_actor_with_detached_pg(self): # Create placement group which is detached. pg = ray.util.placement_group( [{"CPU": 1} for _ in range(2)], strategy="STRICT_SPREAD", lifetime="detached", name="detached_pg", ) ray.get(pg.ready()) # Schedule nested actor with the placement group. for bundle_index in range(2): actor = NestedActor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=bundle_index ), lifetime="detached", ).remote() ray.get(actor.ready.remote()) self.actors.append(actor) a = Actor.options(lifetime="detached").remote() ray.get(a.ready.remote()) # 1 parent actor and 2 children actor. ray.get(a.schedule_nested_actor_with_detached_pg.remote()) # Kill an actor and wait until it is killed. kill_actor_and_wait_for_failure(a) with pytest.raises(ray.exceptions.RayActorError): ray.get(a.ready.remote()) # We should have 2 alive pgs and 4 alive actors. assert assert_alive_num_pg(2) assert assert_alive_num_actor(4) def test_named_placement_group(ray_start_cluster): cluster = ray_start_cluster for _ in range(2): cluster.add_node(num_cpus=3) cluster.wait_for_nodes() info = ray.init(address=cluster.address, namespace="default_test_namespace") global_placement_group_name = "named_placement_group" # Create a detached placement group with name. driver_code = f""" import ray ray.init(address="{info["address"]}", namespace="default_test_namespace") pg = ray.util.placement_group( [{{"CPU": 1}} for _ in range(2)], strategy="STRICT_SPREAD", name="{global_placement_group_name}", lifetime="detached") ray.get(pg.ready()) ray.shutdown() """ run_string_as_driver(driver_code) # Wait until the driver is reported as dead by GCS. def is_job_done(): jobs = ray._private.state.jobs() for job in jobs: if job["IsDead"]: return True return False wait_for_condition(is_job_done) @ray.remote(num_cpus=1) class Actor: def ping(self): return "pong" # Get the named placement group and schedule a actor. placement_group = ray.util.get_placement_group(global_placement_group_name) assert placement_group is not None assert placement_group.wait(5) actor = Actor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=placement_group, placement_group_bundle_index=0 ) ).remote() ray.get(actor.ping.remote()) # Create another placement group and make sure its creation will failed. error_creation_count = 0 try: ray.util.placement_group( [{"CPU": 1} for _ in range(2)], strategy="STRICT_SPREAD", name=global_placement_group_name, ) except RaySystemError: error_creation_count += 1 assert error_creation_count == 1 # Remove a named placement group and make sure the second creation # will successful. ray.util.remove_placement_group(placement_group) same_name_pg = ray.util.placement_group( [{"CPU": 1} for _ in range(2)], strategy="STRICT_SPREAD", name=global_placement_group_name, ) assert same_name_pg.wait(10) # Get a named placement group with a name that doesn't exist # and make sure it will raise ValueError correctly. error_count = 0 try: ray.util.get_placement_group("inexistent_pg") except ValueError: error_count = error_count + 1 assert error_count == 1 def test_placement_group_synchronous_registration(ray_start_cluster): cluster = ray_start_cluster # One node which only has one CPU. cluster.add_node(num_cpus=1) cluster.wait_for_nodes() ray.init(address=cluster.address) # Create a placement group that has two bundles and `STRICT_PACK` # strategy so its registration will successful but scheduling failed. placement_group = ray.util.placement_group( name="name", strategy="STRICT_PACK", bundles=[ { "CPU": 1, }, {"CPU": 1}, ], ) # Make sure we can properly remove it immediately # as its registration is synchronous. ray.util.remove_placement_group(placement_group) wait_for_condition(lambda: is_placement_group_removed(placement_group)) def test_placement_group_gpu_set(ray_start_cluster): cluster = ray_start_cluster # One node which only has one CPU. cluster.add_node(num_cpus=1, num_gpus=1) cluster.add_node(num_cpus=1, num_gpus=1) cluster.wait_for_nodes() ray.init(address=cluster.address) placement_group = ray.util.placement_group( name="name", strategy="PACK", bundles=[{"CPU": 1, "GPU": 1}, {"CPU": 1, "GPU": 1}], ) @ray.remote(num_gpus=1) def get_gpus(): return ray.get_gpu_ids() result = get_gpus.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=placement_group, placement_group_bundle_index=0 ) ).remote() result = ray.get(result) assert result == [0] result = get_gpus.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=placement_group, placement_group_bundle_index=1 ) ).remote() result = ray.get(result) assert result == [0] def test_placement_group_gpu_assigned(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_gpus=2) ray.init(address=cluster.address) gpu_ids_res = set() @ray.remote(num_gpus=1, num_cpus=0) def f(): return os.environ["CUDA_VISIBLE_DEVICES"] pg1 = ray.util.placement_group([{"GPU": 1}]) pg2 = ray.util.placement_group([{"GPU": 1}]) assert pg1.wait(10) assert pg2.wait(10) gpu_ids_res.add( ray.get( f.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg1 ) ).remote() ) ) gpu_ids_res.add( ray.get( f.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg2 ) ).remote() ) ) assert len(gpu_ids_res) == 2 def test_incremental_pg_and_actor_scheduling(ray_start_cluster): """Tests that actors in pending PGs are scheduled as resources become available. For more detailed information please refer to: https://github.com/ray-project/ray/issues/15801. """ cluster = ray_start_cluster cluster.add_node(num_cpus=0) ray.init(address=cluster.address) @ray.remote(num_cpus=1) class A: def ready(self): pass # Schedule a large number of placement groups and actors that should be placed in # those groups. Initially, none are schedulable. pgs = [ray.util.placement_group([{"CPU": 1}]) for _ in range(1000)] pg_refs = [pg.ready() for pg in pgs] actors = [ A.options( scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg) ).remote() for pg in pgs ] actor_refs = [actor.ready.remote() for actor in actors] ready_pgs, _ = ray.wait(pg_refs, timeout=0.1) assert len(ready_pgs) == 0 ready_actors, _ = ray.wait(actor_refs, timeout=0.1) assert len(ready_actors) == 0 def check_num_refs_ready(refs: List[ObjectRef], expected: int) -> bool: ready, _ = ray.wait(refs, num_returns=expected, timeout=1) return len(ready) == expected # Iteratively add nodes to the cluster so that some of the placement groups (and # therefore actors) can be scheduled. Verify that the PGs and actors are scheduled # incrementally as their required resources become available. for i in range(5): cluster.add_node(num_cpus=1) wait_for_condition(lambda: check_num_refs_ready(pg_refs, i + 1), timeout=30) wait_for_condition(lambda: check_num_refs_ready(actor_refs, i + 1), timeout=30) def test_placement_group_gpu_unique_assigned(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_gpus=4, num_cpus=4) ray.init(address=cluster.address) gpu_ids_res = set() # Create placement group with 4 bundles using 1 GPU each. num_gpus = 4 bundles = [{"GPU": 1, "CPU": 1} for _ in range(num_gpus)] pg = placement_group(bundles) ray.get(pg.ready()) # Actor using 1 GPU that has a method to get # $CUDA_VISIBLE_DEVICES env variable. @ray.remote(num_gpus=1, num_cpus=1) class Actor: def get_gpu(self): import os return os.environ["CUDA_VISIBLE_DEVICES"] # Create actors out of order. actors = [] actors.append( Actor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=0 ) ).remote() ) actors.append( Actor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=3 ) ).remote() ) actors.append( Actor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=2 ) ).remote() ) actors.append( Actor.options( scheduling_strategy=PlacementGroupSchedulingStrategy( placement_group=pg, placement_group_bundle_index=1 ) ).remote() ) for actor in actors: gpu_ids = ray.get(actor.get_gpu.remote()) assert len(gpu_ids) == 1 gpu_ids_res.add(gpu_ids) assert len(gpu_ids_res) == 4 @pytest.mark.parametrize("enable_v2", [True, False]) def test_placement_group_status_no_bundle_demand(ray_start_cluster, enable_v2): reset_autoscaler_v2_enabled_cache() cluster = ray_start_cluster cluster.add_node(num_cpus=4, _system_config={"enable_autoscaler_v2": enable_v2}) ray.init(address=cluster.address) @ray.remote def f(): pass pg = ray.util.placement_group([{"CPU": 1}]) ray.get(pg.ready()) ray.util.remove_placement_group(pg) wait_for_condition(lambda: is_placement_group_removed(pg)) # Create a ready task after the placement group is removed. # This shouldn't be reported to the resource demand. r = pg.ready() # noqa # Wait until the usage is updated, which is # when the demand is also updated. def is_usage_updated(): demand_output = get_ray_status_output(cluster.address) return demand_output["usage"] != "" wait_for_condition(is_usage_updated) # The output shouldn't include the pg.ready task demand. demand_output = get_ray_status_output(cluster.address) assert demand_output["demand"] == "(no resource demands)" @pytest.mark.parametrize("enable_v2", [True, False]) def test_placement_group_status(ray_start_cluster, enable_v2): cluster = ray_start_cluster cluster.add_node(num_cpus=4, _system_config={"enable_autoscaler_v2": enable_v2}) ray.init(cluster.address) @ray.remote(num_cpus=1) class A: def ready(self): pass pg = ray.util.placement_group([{"CPU": 1}]) ray.get(pg.ready()) # Wait until the usage is updated to the expected, which is # when the demand is also updated. def is_usage_updated(): demand_output = get_ray_status_output(cluster.address) cpu_usage = demand_output["usage"] if cpu_usage == "": return False cpu_usage = cpu_usage.split("\n")[0] expected = "0.0/4.0 CPU (0.0 used of 1.0 reserved in placement groups)" if cpu_usage != expected: assert cpu_usage == "0.0/4.0 CPU" return False return True wait_for_condition( is_usage_updated, timeout=3 * AUTOSCALER_UPDATE_INTERVAL_S, retry_interval_ms=1000, ) # 2 CPU + 1 PG CPU == 3.0/4.0 CPU (1 used by pg) actors = [A.remote() for _ in range(2)] actors_in_pg = [ A.options( scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg) ).remote() for _ in range(1) ] ray.get([actor.ready.remote() for actor in actors]) ray.get([actor.ready.remote() for actor in actors_in_pg]) def is_pg_usage_propagated(): demand_output = get_ray_status_output(cluster.address) cpu_usage = demand_output["usage"].split("\n")[0] return cpu_usage == "3.0/4.0 CPU (1.0 used of 1.0 reserved in placement groups)" wait_for_condition( is_pg_usage_propagated, timeout=3 * AUTOSCALER_UPDATE_INTERVAL_S, retry_interval_ms=1000, ) def test_placement_group_removal_leak_regression(ray_start_cluster): """Related issue: https://github.com/ray-project/ray/issues/19131 """ cluster = ray_start_cluster cluster.add_node(num_cpus=5) ray.init(address=cluster.address) TOTAL_CPUS = 8 bundles = [{"CPU": 1, "GPU": 1}] bundles += [{"CPU": 1} for _ in range(TOTAL_CPUS - 1)] pg = placement_group(bundles, strategy="PACK") # Here, we simulate that the ready task is queued and # the new node is up. As soon as the new node is up, # the ready task is scheduled. # See https://github.com/ray-project/ray/pull/19138 # for more details about the test. o = pg.ready() # Add an artificial delay until the new node is up. time.sleep(3) cluster.add_node(num_cpus=5, num_gpus=1) ray.get(o) bundle_resource_name = f"bundle_group_{pg.id.hex()}" expected_bundle_wildcard_val = TOTAL_CPUS * 1000 # This should fail if there's a leakage # because the bundle resources are never returned properly. def check_bundle_leaks(): bundle_resources = ray.available_resources()[bundle_resource_name] return expected_bundle_wildcard_val == bundle_resources wait_for_condition(check_bundle_leaks) def test_placement_group_local_resource_view(monkeypatch, ray_start_cluster): """Please refer to https://github.com/ray-project/ray/pull/19911 for more details. """ with monkeypatch.context() as m: # Increase broadcasting interval so that node resource will arrive # at raylet after local resource all being allocated. m.setenv("RAY_raylet_report_resources_period_milliseconds", "2000") cluster = ray_start_cluster cluster.add_node(num_cpus=16, object_store_memory=1e9) cluster.wait_for_nodes() # We need to init here so that we can make sure it's connecting to # the raylet where it only has cpu resources. # This is a hacky way to prevent scheduling hanging which will # schedule job to the node with GPU and for task # there is no node has this resource. ray.init(address="auto") cluster.add_node(num_cpus=16, num_gpus=1) cluster.wait_for_nodes() NUM_CPU_BUNDLES = 30 @ray.remote(num_cpus=1) class Worker(object): def __init__(self, i): self.i = i def work(self): time.sleep(0.1) print("work ", self.i) @ray.remote(num_cpus=1, num_gpus=1) class Trainer(object): def __init__(self, i): self.i = i def train(self): time.sleep(0.2) print("train ", self.i) bundles = [{"CPU": 1, "GPU": 1}] bundles += [{"CPU": 1} for _ in range(NUM_CPU_BUNDLES)] pg = placement_group(bundles, strategy="PACK") ray.get(pg.ready()) # Local resource will be allocated and here we are to ensure # local view is consistent and node resouce updates are discarded workers = [ Worker.options( scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg) ).remote(i) for i in range(NUM_CPU_BUNDLES) ] trainer = Trainer.options( scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=pg) ).remote(0) ray.get([workers[i].work.remote() for i in range(NUM_CPU_BUNDLES)]) ray.get(trainer.train.remote()) def test_fractional_resources_handle_correct(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_cpus=1000) ray.init(address=cluster.address) bundles = [{"CPU": 0.01} for _ in range(5)] pg = placement_group(bundles, strategy="SPREAD") ray.get(pg.ready(), timeout=10) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))