import time from collections import defaultdict from typing import Dict, List, Optional import pytest import ray import ray._private.ray_constants as ray_constants from ray.cluster_utils import Cluster from ray.train._internal.worker_group import Worker, WorkerGroup, WorkerMetadata from ray.util.state import list_actors @pytest.fixture def ray_start_2_cpus(): address_info = ray.init(num_cpus=2) yield address_info # The code after the yield will run as teardown code. ray.shutdown() @pytest.fixture def ray_start_2_cpus_and_gpus(): address_info = ray.init(num_cpus=2, num_gpus=2) yield address_info # The code after the yield will run as teardown code. ray.shutdown() @pytest.fixture def ray_start_2_cpus_and_neuron_core_accelerator(): address_info = ray.init(num_cpus=2, resources={ray_constants.NEURON_CORES: 2}) yield address_info # The code after the yield will run as teardown code. ray.shutdown() @pytest.fixture def ray_start_2_cpus_and_10kb_memory(): address_info = ray.init(num_cpus=2, _memory=10_000) yield address_info # The code after the yield will run as teardown code. ray.shutdown() @pytest.fixture def ray_start_5_nodes_with_memory(): cluster = Cluster() for _ in range(4): cluster.add_node(num_cpus=4, memory=500) cluster.add_node(num_cpus=4, memory=2_000) ray.init(address=cluster.address) yield ray.shutdown() cluster.shutdown() def test_worker_creation(ray_start_2_cpus): assert ray.available_resources()["CPU"] == 2 wg = WorkerGroup(num_workers=2) assert len(wg.workers) == 2 time.sleep(1) # Make sure both CPUs are being used by the actors. assert "CPU" not in ray.available_resources() wg.shutdown() def test_worker_creation_num_cpus(ray_start_2_cpus): assert ray.available_resources()["CPU"] == 2 wg = WorkerGroup(resources_per_worker={"CPU": 2}) time.sleep(1) assert len(wg.workers) == 1 # Make sure both CPUs are being used by the actor. assert "CPU" not in ray.available_resources() wg.shutdown() def test_worker_creation_with_memory(ray_start_5_nodes_with_memory): resources_per_worker = {"memory": 1_000} wg = WorkerGroup(num_workers=2, resources_per_worker=resources_per_worker) assert len(wg.workers) == 2 nodes = ray.nodes() large_node = [node for node in nodes if node["Resources"]["memory"] == 2_000][0] large_node_id = large_node["NodeID"] def validate_scheduling(): resources = ray.get_runtime_context().get_assigned_resources() assert resources == resources_per_worker, "Resources should include memory." node_id = ray.get_runtime_context().get_node_id() assert ( node_id == large_node_id ), "Workers should be scheduled on the large node." wg.execute(validate_scheduling) def test_worker_shutdown(ray_start_2_cpus): assert ray.available_resources()["CPU"] == 2 wg = WorkerGroup(num_workers=2) time.sleep(1) assert "CPU" not in ray.available_resources() assert len(list_actors()) == 2 wg.shutdown() time.sleep(1) assert ray.available_resources()["CPU"] == 2 with pytest.raises(RuntimeError): wg.execute(lambda: 1) def test_worker_restart(ray_start_2_cpus): wg = WorkerGroup(num_workers=2) with pytest.raises(RuntimeError): wg.start() # Avoid race condition. time.sleep(1) wg.shutdown(0) wg.start() wg.execute(lambda: 1) def test_worker_with_gpu_ids(ray_start_2_cpus_and_gpus): num_gpus = 2 wg = WorkerGroup(num_workers=2, resources_per_worker={"GPU": 1}) assert len(wg.workers) == 2 time.sleep(1) assert ray_constants.GPU not in ray.available_resources() wg.execute(lambda: 1) assert len(wg.workers) == 2 for w in wg.workers: resource_ids = w.metadata.resource_ids gpu_ids = resource_ids[ray_constants.GPU] for gpu_id in gpu_ids: assert gpu_id in [str(i) for i in range(num_gpus)] assert len(resource_ids[ray_constants.NEURON_CORES]) == 0 def test_worker_with_neuron_core_accelerator_ids( ray_start_2_cpus_and_neuron_core_accelerator, ): num_nc = 2 wg = WorkerGroup( num_workers=2, resources_per_worker={ray_constants.NEURON_CORES: 1} ) assert len(wg.workers) == 2 time.sleep(1) assert ray_constants.NEURON_CORES not in ray.available_resources() wg.execute(lambda: 1) assert len(wg.workers) == 2 for w in wg.workers: resource_ids = w.metadata.resource_ids assert len(resource_ids[ray_constants.GPU]) == 0 neuron_core_ids = resource_ids[ray_constants.NEURON_CORES] for neuron_core_id in neuron_core_ids: assert neuron_core_id in [str(i) for i in range(num_nc)] def test_execute_async(ray_start_2_cpus): wg = WorkerGroup(num_workers=2) futures = wg.execute_async(lambda: 1) assert len(futures) == 2 outputs = ray.get(futures) assert all(o == 1 for o in outputs) def test_execute(ray_start_2_cpus): wg = WorkerGroup(num_workers=2) outputs = wg.execute(lambda: 1) assert len(outputs) == 2 assert all(o == 1 for o in outputs) def test_execute_args(ray_start_2_cpus): wg = WorkerGroup(num_workers=2) outputs = wg.execute(lambda x: x, 1) assert len(outputs) == 2 assert all(o == 1 for o in outputs) def test_group_workers_by_node_id(ray_start_2_cpus): def create_worker_group(node_ids): wg = WorkerGroup(num_workers=2) wg.workers = [ Worker( actor=None, metadata=WorkerMetadata( node_id=node_id, node_ip="dummy", hostname="dummy", resource_ids={}, pid=0, ), ) for node_id in node_ids ] return wg wg = create_worker_group(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"]) wg.sort_workers_by_node_id_and_gpu_id() expected = ["2", "2", "2", "3", "3", "3", "1", "1", "4", "4"] node_ids = [w.metadata.node_id for w in wg.workers] assert node_ids == expected, ( "Workers should be grouped by Node ID " "and follow the same original order of IDs encountered (2, 3, 1, 4)." ) wg = create_worker_group(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"]) wg.sort_workers_by_node_id_and_gpu_id(_first_node_id="1") expected = ["1", "1", "2", "2", "2", "3", "3", "3", "4", "4"] node_ids = [w.metadata.node_id for w in wg.workers] assert ( node_ids == expected ), "Workers should be grouped by Node ID, with the first ID being 1." def test_sort_local_workers_by_gpu_id(ray_start_2_cpus): def create_worker_group(pids, node_ids, gpu_ids): wg = WorkerGroup(num_workers=2) wg.workers = [ Worker( actor=None, metadata=WorkerMetadata( node_id=node_id, node_ip="dummy", hostname="dummy", resource_ids={"GPU": gpu_id.split() if gpu_id else []}, pid=pid, ), ) for pid, node_id, gpu_id in zip(pids, node_ids, gpu_ids) ] return wg def setup_and_check_worker_group( pids: List[int], node_ids: List[str], gpu_ids: List[Optional[str]], expected_local_ranks: Dict[int, int], ): """ Create a worker group, group workers by Node ID, and check local ranks assignment. Args: pids: List of unique process IDs. node_ids: List of Node IDs corresponding to each PID. gpu_ids: List of GPU IDs or None for each PID. expected_local_ranks: Dictionary mapping PID to the expected local rank. """ wg = create_worker_group(pids=pids, node_ids=node_ids, gpu_ids=gpu_ids) wg.sort_workers_by_node_id_and_gpu_id() # Build local ranks according to the logics in # `BackendExecutor._create_rank_world_size_mappings()` node_id_dict = defaultdict(int) local_ranks_map = defaultdict(int) for w in wg.workers: local_ranks_map[w.metadata.pid] = node_id_dict[w.metadata.node_id] node_id_dict[w.metadata.node_id] += 1 local_ranks = [local_ranks_map[pid] for pid in pids] assert ( local_ranks == expected_local_ranks ), "Incorrect local ranks allocation!\n" f"Expect: {expected_local_ranks}\nGot: {local_ranks}" # Define the worker configurations for different scenarios # For workers without GPU resources, their original order will be preserved cpu_workers_config = { "pids": [0, 1, 2, 3, 4, 5, 6, 7], "node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"], "gpu_ids": [None] * 8, "expected_local_ranks": [0, 1, 0, 1, 2, 2, 3, 3], } gpu_workers_single_gpu_config = { "pids": [0, 1, 2, 3, 4, 5, 6, 7], "node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"], "gpu_ids": ["1", "0", "3", "2", "2", "0", "1", "3"], "expected_local_ranks": [1, 0, 3, 2, 2, 0, 1, 3], } # For workers with multiple gpus, sort by their lowest gpu id gpu_workers_multiple_gpus_config = { "pids": [0, 1, 2, 3], "node_ids": ["2", "1", "1", "2"], "gpu_ids": ["1,3", "2,1", "0,3", "0,2"], "expected_local_ranks": [1, 1, 0, 0], } # Setup and check worker groups for each configuration setup_and_check_worker_group(**cpu_workers_config) setup_and_check_worker_group(**gpu_workers_single_gpu_config) setup_and_check_worker_group(**gpu_workers_multiple_gpus_config) def test_execute_single(ray_start_2_cpus): wg = WorkerGroup(num_workers=2) def f(): import os os.environ["TEST"] = "1" wg.execute_single(1, f) def check(): import os return os.environ.get("TEST", "0") assert wg.execute(check) == ["0", "1"] def test_bad_resources(ray_start_2_cpus): with pytest.raises(ValueError): WorkerGroup(num_workers=-1) with pytest.raises(ValueError): WorkerGroup(resources_per_worker={"CPU": -1}) with pytest.raises(ValueError): WorkerGroup(resources_per_worker={"GPU": -1}) with pytest.raises(ValueError): WorkerGroup(resources_per_worker={"memory": -1}) def test_placement_group(ray_start_2_cpus): """Tests that workers can be removed and added to a placement group.""" num_workers = 2 bundle = {"CPU": 1} bundles = [bundle.copy() for _ in range(num_workers)] placement_group = ray.util.placement_group(bundles) wg = WorkerGroup(num_workers=num_workers, placement_group=placement_group) wg.remove_workers([0]) wg.add_workers(1) if __name__ == "__main__": import sys import pytest sys.exit(pytest.main(["-v", "-x", __file__]))