chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
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__]))
|
||||
Reference in New Issue
Block a user