import asyncio import sys from unittest.mock import MagicMock, patch import pytest from freezegun import freeze_time from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import ( ResourceRequestPriority, ) from ray.train.v2._internal.execution.callback import ControllerCallback from ray.train.v2._internal.execution.scaling_policy import ( AUTOSCALING_REQUESTS_EXPIRE_TIME_S, AUTOSCALING_REQUESTS_INTERVAL_S, NoopDecision, ResizeDecision, ) from ray.train.v2._internal.execution.scaling_policy.elastic import ( ElasticScalingPolicy, ) from ray.train.v2._internal.execution.worker_group import ( WorkerGroupPollStatus, WorkerGroupState, WorkerStatus, ) from ray.train.v2._internal.util import time_monotonic from ray.train.v2.api.config import ScalingConfig @pytest.fixture(autouse=True) def mock_autoscaling_coordinator(monkeypatch): mock_coordinator = MagicMock() mock_coordinator._allocated_resources = None mock_coordinator.get_allocated_resources.remote = MagicMock( side_effect=lambda _: mock_coordinator._allocated_resources ) monkeypatch.setattr( ElasticScalingPolicy, "_autoscaling_coordinator", mock_coordinator ) @pytest.fixture(autouse=True) def patch_ray_get(): with patch( "ray.get", side_effect=lambda x, **_: x, ): yield def _get_mock_worker_group_status(num_workers: int) -> WorkerGroupPollStatus: return WorkerGroupPollStatus( worker_statuses={ i: WorkerStatus(running=True, error=None) for i in range(num_workers) }, ) def _get_mock_worker_group_state( num_workers: int, start_time: float ) -> WorkerGroupState: return WorkerGroupState( start_time=start_time, placement_group_handle=MagicMock(), workers=[MagicMock() for _ in range(num_workers)], sync_actor=MagicMock(), ) @patch.object(ElasticScalingPolicy, "GET_ALLOCATED_RESOURCES_INTERVAL_S", 0.0) def test_non_running_worker_group_decision(): """Test decisions being made when the worker group is initializing/restarting. Ensures that the policy will resize the worker group as soon as resources are available. """ min_workers, max_workers = 4, 64 resources_per_worker = {"CPU": 8, "GPU": 1} scaling_config = ScalingConfig( num_workers=(min_workers, max_workers), resources_per_worker=resources_per_worker, use_gpu=True, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator # No resources are available at the start decision = policy.make_decision_for_non_running_worker_group() assert isinstance(decision, NoopDecision) # Resources for < min workers are available mock_coordinator._allocated_resources = [resources_per_worker] * (min_workers - 1) decision = policy.make_decision_for_non_running_worker_group() assert isinstance(decision, NoopDecision) # Resources for >= min workers are available mock_coordinator._allocated_resources = [resources_per_worker] * min_workers decision = policy.make_decision_for_non_running_worker_group() assert isinstance(decision, ResizeDecision) assert decision.num_workers == min_workers # Resources for >= max workers are available mock_coordinator._allocated_resources = [resources_per_worker] * max_workers decision = policy.make_decision_for_non_running_worker_group() assert isinstance(decision, ResizeDecision) assert decision.num_workers == max_workers def test_before_controller_abort(): """Test that before_controller_abort sends a cancel request to the AutoscalingCoordinator.""" resources_per_worker = {"CPU": 4, "GPU": 1} scaling_config = ScalingConfig( num_workers=(2, 4), resources_per_worker=resources_per_worker, use_gpu=True, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator # Call before_controller_abort and check that cancel_request is called with the requester_id policy.before_controller_abort() mock_coordinator.cancel_request.remote.assert_called_once_with( requester_id=policy._requester_id ) def test_get_allocated_resources_interval(): """Tests that remote calls to the AutoscalingCoordinator are spaced out by a minimum time interval.""" min_workers, max_workers = 4, 64 resources_per_worker = {"CPU": 8, "GPU": 1} get_allocated_resources_interval_s = ( ElasticScalingPolicy.GET_ALLOCATED_RESOURCES_INTERVAL_S ) scaling_config = ScalingConfig( num_workers=(min_workers, max_workers), resources_per_worker=resources_per_worker, use_gpu=True, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator with freeze_time() as frozen_time: # No resources are available at the start allocated_resources = policy._get_allocated_resources() assert allocated_resources is None # Resources for < min workers are available frozen_time.tick(get_allocated_resources_interval_s) mock_coordinator._allocated_resources = [resources_per_worker] * ( min_workers - 1 ) allocated_resources = policy._get_allocated_resources() assert allocated_resources == [resources_per_worker] * (min_workers - 1) # Resources for >= min workers are available, but get_allocated_resources interval # has not yet passed. mock_coordinator._allocated_resources = [resources_per_worker] * min_workers allocated_resources = policy._get_allocated_resources() assert allocated_resources == [resources_per_worker] * (min_workers - 1) # Resources for >= min workers are available and the get_allocated_resources # interval has passed. frozen_time.tick(get_allocated_resources_interval_s) mock_coordinator._allocated_resources = [resources_per_worker] * min_workers allocated_resources = policy._get_allocated_resources() assert allocated_resources == [resources_per_worker] * min_workers # Resources for >= max workers are available but the get_allocated_resources # interval has not yet passed. mock_coordinator._allocated_resources = [resources_per_worker] * max_workers allocated_resources = policy._get_allocated_resources() assert allocated_resources == [resources_per_worker] * min_workers # Resources for >= max workers are available and the get_allocated_resources # interval has passed. frozen_time.tick(get_allocated_resources_interval_s) allocated_resources = policy._get_allocated_resources() assert allocated_resources == [resources_per_worker] * max_workers @patch.object(ElasticScalingPolicy, "GET_ALLOCATED_RESOURCES_INTERVAL_S", 0.0) def test_running_worker_group_decision(): """Test decisions being made when the worker group is running. Ensures that the policy will resize the worker group when there is a change in available resources. """ min_workers, max_workers = 4, 64 resources_per_worker = {"CPU": 8, "GPU": 1} scaling_config = ScalingConfig( num_workers=(min_workers, max_workers), resources_per_worker=resources_per_worker, use_gpu=True, # NOTE: This test just asserts the policy decisions, not the monitor interval. elastic_resize_monitor_interval_s=0.0, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator # The worker group just started worker_group_state = _get_mock_worker_group_state(min_workers, time_monotonic()) worker_group_status = _get_mock_worker_group_status(min_workers) # No change in resources mock_coordinator._allocated_resources = [resources_per_worker] * min_workers decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, NoopDecision) # Resources for < min workers are available mock_coordinator._allocated_resources = [resources_per_worker] * (min_workers - 1) decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, NoopDecision) # More resources are available. mock_coordinator._allocated_resources = [resources_per_worker] * max_workers decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, ResizeDecision) assert decision.num_workers == max_workers def test_monitor_recently_started_worker_group(): """Test monitor decisions being made when the worker group is running. Ensures that resizing decisions are not made too soon after the worker group starts. """ min_workers, max_workers = 4, 64 monitor_interval_s = 60 resources_per_worker = {"CPU": 8, "GPU": 1} scaling_config = ScalingConfig( num_workers=(min_workers, max_workers), resources_per_worker=resources_per_worker, use_gpu=True, elastic_resize_monitor_interval_s=monitor_interval_s, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator with freeze_time() as frozen_time: # The worker group just started worker_group_state = _get_mock_worker_group_state(min_workers, time_monotonic()) worker_group_status = _get_mock_worker_group_status(min_workers) # Advance time partway through the monitor interval frozen_time.tick(delta=monitor_interval_s / 2) # Even though there are new resources available, we should not resize yet # because the monitor interval has not passed since mock_coordinator._allocated_resources = [resources_per_worker] * ( max_workers - 1 ) assert isinstance( policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ), NoopDecision, ) frozen_time.tick(delta=monitor_interval_s / 2) # The monitor interval has passed, should detect resources and resize decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, ResizeDecision) assert decision.num_workers == max_workers - 1 def test_monitor_long_running_worker_group(): """Test monitor decisions being made when the worker group is running. Ensures that the resizing considerations are not made too frequently. """ min_workers, max_workers = 4, 64 monitor_interval_s = 60 resources_per_worker = {"CPU": 8, "GPU": 1} scaling_config = ScalingConfig( num_workers=(min_workers, max_workers), resources_per_worker=resources_per_worker, use_gpu=True, elastic_resize_monitor_interval_s=monitor_interval_s, ) policy = ElasticScalingPolicy(scaling_config) mock_coordinator = policy._autoscaling_coordinator with freeze_time() as frozen_time: worker_group_state = _get_mock_worker_group_state(min_workers, time_monotonic()) worker_group_status = _get_mock_worker_group_status(min_workers) mock_coordinator._allocated_resources = [resources_per_worker] * min_workers # The worker group has been running for a while at the same size frozen_time.tick(monitor_interval_s * 60) # Consider resizing. decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, NoopDecision) # We recently considered resizing, so we should wait until the next interval # to consider again --> no-op even if new resources are available mock_coordinator._allocated_resources = [resources_per_worker] * max_workers frozen_time.tick(monitor_interval_s / 2) decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, NoopDecision) frozen_time.tick(monitor_interval_s / 2) decision = policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert isinstance(decision, ResizeDecision) assert decision.num_workers == max_workers def test_count_possible_workers(): """Test counting the number of workers that can be started with available node resources.""" resources_per_worker = {"CPU": 8, "GPU": 1} scaling_config = ScalingConfig( num_workers=(1, 8), use_gpu=True, resources_per_worker=resources_per_worker, ) policy = ElasticScalingPolicy(scaling_config) # No resources assert policy._count_possible_workers([]) == 0 # Single node assert policy._count_possible_workers([{"CPU": 8, "GPU": 1}]) == 1 assert policy._count_possible_workers([{"CPU": 16, "GPU": 2}]) == 2 assert policy._count_possible_workers([{"CPU": 16, "GPU": 1}]) == 1 # Multinode assert policy._count_possible_workers([{"CPU": 7, "GPU": 1}] * 2) == 0 assert policy._count_possible_workers([{"CPU": 9, "GPU": 2}] * 8) == 8 assert policy._count_possible_workers([{"CPU": 16, "GPU": 2}] * 2) == 4 assert policy._count_possible_workers([{"CPU": 8, "GPU": 1}] * 4) == 4 # If there are excess resources, the number of workers is still capped at max_workers assert policy._count_possible_workers([{"CPU": 16, "GPU": 2}] * 10) == 8 def test_count_possible_workers_with_zero_resources(): max_workers = 4 scaling_config = ScalingConfig( num_workers=(1, max_workers), resources_per_worker={"CPU": 0, "GPU": 0, "memory": 0}, ) policy = ElasticScalingPolicy(scaling_config) assert ( policy._count_possible_workers([{"CPU": 1, "GPU": 1, "memory": 1}]) == max_workers ) def test_request_and_clear(): """Tests that the policy makes resource requests and clears the requests.""" resources_per_worker = {"CPU": 8, "GPU": 1} policy = ElasticScalingPolicy( scaling_config=ScalingConfig( use_gpu=True, resources_per_worker=resources_per_worker, num_workers=(2, 4) ) ) assert isinstance(policy, ControllerCallback) mock_coordinator = policy._autoscaling_coordinator def assert_resource_request_called_with(): nonlocal mock_coordinator mock_coordinator.request_resources.remote.assert_called_with( requester_id=policy._requester_id, resources=[resources_per_worker] * 4, label_selectors=None, expire_after_s=AUTOSCALING_REQUESTS_EXPIRE_TIME_S, priority=ResourceRequestPriority.HIGH, ) with freeze_time() as frozen_time: worker_group_state = _get_mock_worker_group_state(2, time_monotonic()) worker_group_status = _get_mock_worker_group_status(2) # Test request_resources is called when the controller starts. policy.after_controller_start(train_run_context=MagicMock()) assert mock_coordinator.request_resources.remote.call_count == 1 assert_resource_request_called_with() # Test request_resources is only called in # `make_decision_for_running_worker_group`, # if `AUTOSCALING_REQUESTS_INTERVAL_S` has passed. frozen_time.tick(AUTOSCALING_REQUESTS_INTERVAL_S / 2) policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert mock_coordinator.request_resources.remote.call_count == 1 frozen_time.tick(AUTOSCALING_REQUESTS_INTERVAL_S / 2) policy.make_decision_for_running_worker_group( worker_group_state=worker_group_state, worker_group_status=worker_group_status, ) assert mock_coordinator.request_resources.remote.call_count == 2 assert_resource_request_called_with() # Test cancel_request is called when the controller is shutting down. asyncio.run(policy.before_controller_shutdown()) mock_coordinator.cancel_request.remote.assert_called_once() @pytest.mark.parametrize( "num_autoscaler_nodes, mock_gcs_intact_slices, expected_workers", [ # No resources -> 0 workers (0, 0, 0), # Autoscaler sees 3 nodes but slice requires 4 -> rounds to 0 workers (3, 0, 0), # Autoscaler sees 4 nodes and 1 intact slice -> 4 workers (4, 1, 4), # Autoscaler sees 8 nodes (2 slices worth) but only 1 slice is intact # (e.g. 1 host down in the other slice) -> capped at 4 workers (8, 1, 4), # Autoscaler sees 8 nodes and both slices are intact -> 8 workers (8, 2, 8), # Autoscaler sees 12 nodes (3 slices), all 3 intact -> 12 workers (max) (12, 3, 12), ], ) @patch("ray.util.tpu.get_num_tpu_slices") def test_count_possible_workers_tpu_slice_rounding( mock_get_intact_slices, num_autoscaler_nodes, mock_gcs_intact_slices, expected_workers, ): """ Test that TPU scaling correctly floors to the nearest complete, physically intact TPU slice. Intact slices are counted regardless of whether they are currently idle or in use. """ mock_get_intact_slices.return_value = mock_gcs_intact_slices # Scaling config for TPU v6e 4x4 between 1 and 3 slices. scaling_config = ScalingConfig( use_tpu=True, accelerator_type="TPU-V6E", topology="4x4", num_workers=(4, 12), resources_per_worker={"TPU": 4, "CPU": 1}, ) policy = ElasticScalingPolicy(scaling_config) tpu_node = {"TPU": 4, "CPU": 1, "accelerator_type:TPU-V6E": 1} allocated_resources = [tpu_node] * num_autoscaler_nodes assert policy._count_possible_workers(allocated_resources) == expected_workers if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))