Files
ray-project--ray/python/ray/data/tests/test_default_cluster_autoscaler_v2.py
2026-07-13 13:17:40 +08:00

963 lines
38 KiB
Python

import logging
from unittest.mock import MagicMock, Mock, patch
import pytest
import ray
from ray.core.generated import autoscaler_pb2
from ray.data._internal import cluster_autoscaler as ca_pkg
from ray.data._internal.cluster_autoscaler import create_cluster_autoscaler
from ray.data._internal.cluster_autoscaler.default_cluster_autoscaler_v2 import (
DefaultClusterAutoscalerV2,
_get_node_resource_spec_and_count,
_NodeResourceSpec,
)
from ray.data._internal.cluster_autoscaler.fake_autoscaling_coordinator import (
FakeAutoscalingCoordinator,
)
from ray.data._internal.cluster_autoscaler.resource_utilization_gauge import (
ResourceUtilizationGauge,
)
from ray.data._internal.execution.interfaces.execution_options import ExecutionResources
from ray.data._internal.util import GiB
class StubUtilizationGauge(ResourceUtilizationGauge):
def __init__(self, utilization: ExecutionResources):
self._utilization = utilization
def observe(self):
pass
def get(self):
return self._utilization
_IS_AUTOSCALING_ENABLED_PATH = (
"ray.data._internal.cluster_autoscaler."
"default_cluster_autoscaler_v2.is_autoscaling_enabled"
)
class TestClusterAutoscaling:
"""Tests for cluster autoscaling functions in DefaultClusterAutoscalerV2."""
def setup_class(self):
self._node_type1 = {
"CPU": 4,
"memory": 1000,
"object_store_memory": 500,
}
self._node_type2 = {
"CPU": 8,
"memory": 2000,
"object_store_memory": 500,
}
self._node_type3 = {
"CPU": 4,
"GPU": 1,
"memory": 1000,
"object_store_memory": 500,
}
self._head_node = {
"CPU": 4,
"memory": 1000,
"object_store_memory": 500,
"node:__internal_head__": 1.0,
}
ray.init()
def teardown_class(self):
ray.shutdown()
def test_get_node_resource_spec_and_count(self):
# Test _get_node_resource_spec_and_count
node_table = [
{
"Resources": self._head_node,
"Alive": True,
},
{
"Resources": self._node_type1,
"Alive": True,
},
{
"Resources": self._node_type2,
"Alive": True,
},
{
"Resources": self._node_type3,
"Alive": True,
},
{
"Resources": self._node_type1,
"Alive": True,
},
{
"Resources": self._node_type2,
"Alive": False,
},
]
expected = {
_NodeResourceSpec.of(
cpu=self._node_type1["CPU"],
gpu=self._node_type1.get("GPU", 0),
mem=self._node_type1["memory"],
): 2,
_NodeResourceSpec.of(
cpu=self._node_type2["CPU"],
gpu=self._node_type2.get("GPU", 0),
mem=self._node_type2["memory"],
): 1,
_NodeResourceSpec.of(
cpu=self._node_type3["CPU"],
gpu=self._node_type3.get("GPU", 0),
mem=self._node_type3["memory"],
): 1,
}
# Patch cluster config to return None
with (
patch("ray.nodes", return_value=node_table),
patch(
"ray._private.state.state.get_cluster_config",
return_value=None,
),
):
assert _get_node_resource_spec_and_count() == expected
def test_get_node_resource_spec_and_count_filters_by_subcluster(self):
"""Only nodes whose ``subcluster`` label matches contribute to
the counts. Prevents ``try_trigger_scaling`` from pulling shapes
and counts from foreign subclusters into a labeled requester's
active / pending bundles."""
node_table = [
{
"Resources": self._node_type1,
"Labels": {"ray-subcluster": "training"},
"Alive": True,
},
{
"Resources": self._node_type1,
"Labels": {"ray-subcluster": "training"},
"Alive": True,
},
{
"Resources": self._node_type2,
"Labels": {"ray-subcluster": "validation"},
"Alive": True,
},
{
"Resources": self._node_type3,
"Labels": {},
"Alive": True,
},
]
with (
patch("ray.nodes", return_value=node_table),
patch(
"ray._private.state.state.get_cluster_config",
return_value=None,
),
):
assert _get_node_resource_spec_and_count(subcluster="training") == {
_NodeResourceSpec.of(
cpu=self._node_type1["CPU"],
gpu=self._node_type1.get("GPU", 0),
mem=self._node_type1["memory"],
): 2,
}
@pytest.mark.parametrize("cpu_util", [0.5, 0.75])
@pytest.mark.parametrize("gpu_util", [0.5, 0.75])
@pytest.mark.parametrize("mem_util", [0.5, 0.75])
def test_try_scale_up_cluster(self, cpu_util, gpu_util, mem_util):
# Test _try_scale_up_cluster
scale_up_threshold = 0.75
scale_up_delta = 1
utilization = ExecutionResources(
cpu=cpu_util, gpu=gpu_util, object_store_memory=mem_util
)
fake_coordinator = FakeAutoscalingCoordinator()
resource_spec1 = _NodeResourceSpec.of(cpu=4, gpu=0, mem=1000)
resource_spec2 = _NodeResourceSpec.of(cpu=8, gpu=1, mem=1000)
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=ExecutionResources.inf(),
execution_id="test_execution_id",
cluster_scaling_up_delta=scale_up_delta,
resource_utilization_calculator=StubUtilizationGauge(utilization),
cluster_scaling_up_util_threshold=scale_up_threshold,
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {resource_spec1: 2, resource_spec2: 1},
)
autoscaler.try_trigger_scaling()
# Should scale up if any resource is above the threshold.
should_scale_up = (
cpu_util >= scale_up_threshold
or gpu_util >= scale_up_threshold
or mem_util >= scale_up_threshold
)
resources_allocated = autoscaler.get_total_resources()
if not should_scale_up:
assert resources_allocated == ExecutionResources.zero()
else:
expected_num_resource_spec1_requested = 2 + scale_up_delta
expected_num_resource_spec2_requested = 1 + scale_up_delta
expected_resources = ExecutionResources(
cpu=(
resource_spec1.cpu * expected_num_resource_spec1_requested
+ resource_spec2.cpu * expected_num_resource_spec2_requested
),
gpu=(
resource_spec1.gpu * expected_num_resource_spec1_requested
+ resource_spec2.gpu * expected_num_resource_spec2_requested
),
memory=(
resource_spec1.mem * expected_num_resource_spec1_requested
+ resource_spec2.mem * expected_num_resource_spec2_requested
),
)
assert resources_allocated == expected_resources
def test_get_node_resource_spec_and_count_from_zero(self):
"""Test that get_node_resource_spec_and_count can discover node types
from cluster config even when there are zero worker nodes."""
# Simulate a cluster with only head node (no worker nodes)
node_table = [
{
"Resources": self._head_node,
"Alive": True,
},
]
# Create a mock cluster config with 2 worker node types
cluster_config = autoscaler_pb2.ClusterConfig()
# Node type 1: 4 CPU, 0 GPU, 1000 memory
node_group_config1 = autoscaler_pb2.NodeGroupConfig()
node_group_config1.resources["CPU"] = 4
node_group_config1.resources["memory"] = 1000
node_group_config1.max_count = 10
cluster_config.node_group_configs.append(node_group_config1)
# Node type 2: 8 CPU, 2 GPU, 2000 memory
node_group_config2 = autoscaler_pb2.NodeGroupConfig()
node_group_config2.resources["CPU"] = 8
node_group_config2.resources["GPU"] = 2
node_group_config2.resources["memory"] = 2000
node_group_config2.max_count = 5
cluster_config.node_group_configs.append(node_group_config2)
expected = {
_NodeResourceSpec.of(cpu=4, gpu=0, mem=1000): 0,
_NodeResourceSpec.of(cpu=8, gpu=2, mem=2000): 0,
}
with patch("ray.nodes", return_value=node_table):
with patch(
"ray._private.state.state.get_cluster_config",
return_value=cluster_config,
):
result = _get_node_resource_spec_and_count()
assert result == expected
def test_try_scale_up_cluster_from_zero(self):
"""Test that the autoscaler can scale up from zero worker nodes."""
scale_up_threshold = 0.75
scale_up_delta = 1
# High utilization to trigger scaling
utilization = ExecutionResources(cpu=0.9, gpu=0.9, object_store_memory=0.9)
# Mock the node resource spec with zero counts
resource_spec1 = _NodeResourceSpec.of(cpu=4, gpu=0, mem=1000)
resource_spec2 = _NodeResourceSpec.of(cpu=8, gpu=2, mem=2000)
fake_coordinator = FakeAutoscalingCoordinator()
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=ExecutionResources.inf(),
execution_id="test_execution_id",
cluster_scaling_up_delta=scale_up_delta,
resource_utilization_calculator=StubUtilizationGauge(utilization),
cluster_scaling_up_util_threshold=scale_up_threshold,
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {
resource_spec1: 0,
resource_spec2: 0,
},
)
autoscaler.try_trigger_scaling()
# Should request scale_up_delta nodes of each type
# Verify via get_total_resources which returns what was allocated
resources_allocated = autoscaler.get_total_resources()
expected_resources = ExecutionResources(
cpu=resource_spec1.cpu * scale_up_delta
+ resource_spec2.cpu * scale_up_delta,
gpu=resource_spec1.gpu * scale_up_delta
+ resource_spec2.gpu * scale_up_delta,
memory=resource_spec1.mem * scale_up_delta
+ resource_spec2.mem * scale_up_delta,
)
assert resources_allocated == expected_resources
def test_low_utilization_sends_current_allocation(self):
"""Test that low utilization sends current allocation.
Test scenario:
1. Dataset has already been allocated resources (1 nodes)
2. Utilization is low (0%, below default threshold)
3. Should send current allocation to preserve resource footprint
"""
utilization: ExecutionResources = ...
class FakeUtilizationGauge(ResourceUtilizationGauge):
def observe(self):
pass
def get(self):
return utilization
node_resource_spec = _NodeResourceSpec.of(cpu=1, gpu=0, mem=0)
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=ExecutionResources.inf(),
execution_id="test_execution_id",
resource_utilization_calculator=FakeUtilizationGauge(),
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=FakeAutoscalingCoordinator(),
get_node_counts=lambda: {node_resource_spec: 0},
)
# Trigger scaling with high utilization. The cluster autoscaler should request
# one node.
utilization = ExecutionResources(cpu=1)
autoscaler.try_trigger_scaling()
assert autoscaler.get_total_resources() == ExecutionResources(cpu=1)
# Trigger scaling with low utilization. The cluster autoscaler should re-request
# one node rather than no resources.
utilization = ExecutionResources(cpu=0)
autoscaler.try_trigger_scaling()
assert autoscaler.get_total_resources() == ExecutionResources(cpu=1)
def test_low_utilization_grace_period_keeps_explicit_request(self):
"""Below the scale-up threshold, the last explicit request is resent briefly.
This avoids immediately dropping explicit autoscaler demand (and avoids
re-submitting ``get_allocated_resources()`` shapes as explicit demand).
"""
current_time = {"t": 0.0}
def get_time() -> float:
return current_time["t"]
node_resource_spec = _NodeResourceSpec.of(cpu=1, gpu=0, mem=0)
fake_coordinator = FakeAutoscalingCoordinator(get_time=get_time)
utilization_holder = {
"u": ExecutionResources(
cpu=0.9, gpu=0.9, object_store_memory=0.9, memory=0.9
)
}
class MutableUtilGauge(ResourceUtilizationGauge):
def observe(self):
pass
def get(self):
return utilization_holder["u"]
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=ExecutionResources.inf(),
execution_id="test_low_util_grace",
resource_utilization_calculator=MutableUtilGauge(),
min_gap_between_autoscaling_requests_s=0,
low_util_request_release_delay_s=100,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {node_resource_spec: 0},
get_time=get_time,
)
autoscaler.AUTOSCALING_REQUEST_EXPIRE_TIME_S = 3600
current_time["t"] = 10.0
autoscaler.try_trigger_scaling()
expected = ExecutionResources(cpu=1.0)
assert autoscaler.get_total_resources() == expected
utilization_holder["u"] = ExecutionResources.zero()
current_time["t"] = 20.0
autoscaler.try_trigger_scaling()
assert autoscaler.get_total_resources() == expected
def test_low_utilization_after_grace_sends_empty_request(self):
"""After the grace window, low utilization renews with an empty request."""
current_time = {"t": 0.0}
def get_time() -> float:
return current_time["t"]
node_resource_spec = _NodeResourceSpec.of(cpu=1, gpu=0, mem=0)
fake_coordinator = FakeAutoscalingCoordinator(get_time=get_time)
utilization_holder = {
"u": ExecutionResources(
cpu=0.9, gpu=0.9, object_store_memory=0.9, memory=0.9
)
}
class MutableUtilGauge(ResourceUtilizationGauge):
def observe(self):
pass
def get(self):
return utilization_holder["u"]
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=ExecutionResources.inf(),
execution_id="test_low_util_release",
resource_utilization_calculator=MutableUtilGauge(),
min_gap_between_autoscaling_requests_s=0,
low_util_request_release_delay_s=100,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {node_resource_spec: 0},
get_time=get_time,
)
autoscaler.AUTOSCALING_REQUEST_EXPIRE_TIME_S = 3600
current_time["t"] = 10.0
autoscaler.try_trigger_scaling()
assert autoscaler.get_total_resources() == ExecutionResources(cpu=1.0)
utilization_holder["u"] = ExecutionResources.zero()
current_time["t"] = 200.0
autoscaler.try_trigger_scaling()
assert autoscaler.get_total_resources() == ExecutionResources.zero()
def test_get_node_resource_spec_and_count_skips_max_count_zero(self):
"""Test that node types with max_count=0 are skipped."""
# Simulate a cluster with only head node (no worker nodes)
node_table = [
{
"Resources": self._head_node,
"Alive": True,
},
]
# Create a mock cluster config with one valid node type and one with max_count=0
cluster_config = autoscaler_pb2.ClusterConfig()
# Node type 1: 4 CPU, 0 GPU, 1000 memory, max_count=10
node_group_config1 = autoscaler_pb2.NodeGroupConfig()
node_group_config1.resources["CPU"] = 4
node_group_config1.resources["memory"] = 1000
node_group_config1.max_count = 10
cluster_config.node_group_configs.append(node_group_config1)
# Node type 2: 8 CPU, 2 GPU, 2000 memory, max_count=0 (should be skipped)
node_group_config2 = autoscaler_pb2.NodeGroupConfig()
node_group_config2.resources["CPU"] = 8
node_group_config2.resources["GPU"] = 2
node_group_config2.resources["memory"] = 2000
node_group_config2.max_count = 0 # This should be skipped
cluster_config.node_group_configs.append(node_group_config2)
# Only the first node type should be discovered
expected = {
_NodeResourceSpec.of(cpu=4, gpu=0, mem=1000): 0,
}
with patch("ray.nodes", return_value=node_table):
with patch(
"ray._private.state.state.get_cluster_config",
return_value=cluster_config,
):
result = _get_node_resource_spec_and_count()
assert result == expected
@pytest.mark.parametrize(
"nodes,node_groups,subcluster,expected",
[
pytest.param(
# Head node with CPU zeroed out (as configured to avoid
# scheduling tasks on the head), plus 2 worker nodes.
[
{"resources": {"memory": 32 * GiB, "node:__internal_head__": 1.0}},
{"resources": {"CPU": 8, "memory": 32 * GiB}},
{"resources": {"CPU": 8, "memory": 32 * GiB}},
],
[
# Worker group: can scale up to 10 nodes.
{"resources": {"CPU": 8, "memory": 32 * GiB}, "max_count": 10},
# Dedicated head group: matches the head node, max_count == 1.
{"resources": {"memory": 32 * GiB}, "max_count": 1},
],
None,
# Only the worker shape is present (with the 2 running workers);
# the dedicated head group is excluded entirely.
{_NodeResourceSpec.of(cpu=8, gpu=0, mem=32 * GiB): 2},
id="dedicated_head_group_excluded",
),
pytest.param(
# Head node group that can also host workers (max_count > 1):
# scaling its shape adds a worker, so it must be kept.
[
{
"resources": {
"CPU": 4,
"memory": 1000,
"node:__internal_head__": 1.0,
}
}
],
[{"resources": {"CPU": 4, "memory": 1000}, "max_count": 3}],
None,
{_NodeResourceSpec.of(cpu=4, gpu=0, mem=1000): 0},
id="head_group_with_workers_kept",
),
pytest.param(
# Worker group limited to a single node with a shape that does
# NOT match the head node: must not be over-excluded.
[
{
"resources": {
"CPU": 4,
"memory": 1000,
"node:__internal_head__": 1.0,
}
}
],
[
{
"resources": {"CPU": 8, "GPU": 2, "memory": 2000},
"max_count": 1,
}
],
None,
{_NodeResourceSpec.of(cpu=8, gpu=2, mem=2000): 0},
id="single_worker_group_kept",
),
pytest.param(
# Head + subcluster interaction: the head node has no subcluster
# label, and there are workers in two subclusters. Computing for
# "training" must drop the dedicated head group (head detection
# is global, not subcluster-scoped) and count only the training
# workers, ignoring the validation worker.
[
{"resources": {"memory": 32 * GiB, "node:__internal_head__": 1.0}},
{
"resources": {"CPU": 8, "memory": 32 * GiB},
"labels": {"ray-subcluster": "training"},
},
{
"resources": {"CPU": 8, "memory": 32 * GiB},
"labels": {"ray-subcluster": "training"},
},
{
"resources": {"CPU": 4, "memory": 16 * GiB},
"labels": {"ray-subcluster": "validation"},
},
],
[
{"resources": {"CPU": 8, "memory": 32 * GiB}, "max_count": 10},
{"resources": {"memory": 32 * GiB}, "max_count": 1},
],
"training",
{_NodeResourceSpec.of(cpu=8, gpu=0, mem=32 * GiB): 2},
id="dedicated_head_excluded_and_subcluster_scoped",
),
],
)
def test_get_node_resource_spec_and_count_head_node_group(
self, nodes, node_groups, subcluster, expected
):
"""The head node must not be scaled up, but worker-capable groups are.
A node group is only excluded when it's dedicated to the head node
(``max_count == 1`` and a shape matching the running head node). Groups
that can host workers (``max_count > 1``) or that have a non-head shape
are kept so scale-from-zero still works. Head detection spans the whole
cluster, while worker counting is scoped to ``subcluster``.
"""
node_table = [
{
"Resources": node["resources"],
"Labels": node.get("labels", {}),
"Alive": True,
}
for node in nodes
]
cluster_config = autoscaler_pb2.ClusterConfig()
for group in node_groups:
node_group_config = autoscaler_pb2.NodeGroupConfig()
for name, value in group["resources"].items():
node_group_config.resources[name] = value
node_group_config.max_count = group["max_count"]
cluster_config.node_group_configs.append(node_group_config)
with patch("ray.nodes", return_value=node_table):
with patch(
"ray._private.state.state.get_cluster_config",
return_value=cluster_config,
):
result = _get_node_resource_spec_and_count(subcluster=subcluster)
assert result == expected
def test_get_node_resource_spec_and_count_missing_all_resources(self):
"""Regression test for nodes with empty resources (ie missing CPU, GPU, and memory keys entirely)."""
# Simulate a node with no standard resources defined
node_empty_resources = {
"Alive": True,
"Resources": {
"dummy_resource": 1,
},
}
node_table = [
{
"Resources": self._head_node,
"Alive": True,
},
node_empty_resources,
]
# Expect everything to default to 0
expected = {_NodeResourceSpec.of(cpu=0, gpu=0, mem=0): 1}
with (
patch("ray.nodes", return_value=node_table),
patch(
"ray._private.state.state.get_cluster_config",
return_value=None,
),
):
result = _get_node_resource_spec_and_count()
assert result == expected
@pytest.mark.parametrize(
"resource_limits,node_spec,existing_nodes,scale_up_increment,expected_nodes",
[
# CPU limit: 8 CPUs allows 2 nodes (8 CPUs), not 3 (12 CPUs)
(
ExecutionResources.for_limits(cpu=8),
_NodeResourceSpec.of(cpu=4, gpu=0, mem=1000),
2,
1,
2,
),
# GPU limit: 2 GPUs allows 2 nodes (2 GPUs), not 3 (3 GPUs)
(
ExecutionResources.for_limits(gpu=2),
_NodeResourceSpec.of(cpu=4, gpu=1, mem=1000),
2,
1,
2,
),
# Memory limit: 4 GiB allows 2 nodes (4 GiB), not 3 (6 GiB)
(
ExecutionResources.for_limits(memory=4 * GiB),
_NodeResourceSpec.of(cpu=4, gpu=0, mem=2 * GiB),
2,
1,
2,
),
# No limits: all 3 nodes (2 existing + 1 delta) should be requested
(
ExecutionResources.inf(),
_NodeResourceSpec.of(cpu=4, gpu=0, mem=1000),
2,
1,
3,
),
],
)
def test_try_scale_up_respects_resource_limits(
self,
resource_limits,
node_spec,
existing_nodes,
scale_up_increment,
expected_nodes,
):
"""Test that cluster autoscaling respects user-configured resource limits."""
scale_up_threshold = 0.75
# High utilization to trigger scaling
utilization = ExecutionResources(cpu=0.9, gpu=0.9, object_store_memory=0.9)
fake_coordinator = FakeAutoscalingCoordinator()
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=resource_limits,
execution_id="test_execution_id",
cluster_scaling_up_delta=scale_up_increment,
resource_utilization_calculator=StubUtilizationGauge(utilization),
cluster_scaling_up_util_threshold=scale_up_threshold,
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {node_spec: existing_nodes},
)
autoscaler.try_trigger_scaling()
resources_allocated = autoscaler.get_total_resources()
assert resources_allocated.cpu == node_spec.cpu * expected_nodes
assert resources_allocated.gpu == node_spec.gpu * expected_nodes
assert resources_allocated.memory == node_spec.mem * expected_nodes
def test_try_scale_up_respects_resource_limits_heterogeneous_nodes(self):
"""Test that smaller bundles are included even when larger bundles exceed limits.
This tests a scenario where:
1. Initial cluster (1 small node, 4 CPUs) is within the budget (10 CPUs)
2. Scaling up is triggered due to high utilization
3. The autoscaler wants to add both large and small nodes
4. Only small nodes are requested because large nodes would exceed the limit
"""
# CPU limit of 10 allows the initial state (4 CPUs) plus room for growth
resource_limits = ExecutionResources.for_limits(cpu=10)
large_node_spec = _NodeResourceSpec.of(cpu=8, gpu=1, mem=4 * GiB)
small_node_spec = _NodeResourceSpec.of(cpu=4, gpu=0, mem=2 * GiB)
scale_up_threshold = 0.75
utilization = ExecutionResources(cpu=0.9, gpu=0.9, object_store_memory=0.9)
fake_coordinator = FakeAutoscalingCoordinator()
# Initial cluster: 1 small node (4 CPUs) - within the 10 CPU budget
# Node types available: large (8 CPUs) and small (4 CPUs)
def get_heterogeneous_nodes():
return {
large_node_spec: 0, # 0 existing large nodes
small_node_spec: 1, # 1 existing small node (4 CPUs)
}
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=resource_limits,
execution_id="test_execution_id",
cluster_scaling_up_delta=1,
resource_utilization_calculator=StubUtilizationGauge(utilization),
cluster_scaling_up_util_threshold=scale_up_threshold,
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=get_heterogeneous_nodes,
)
autoscaler.try_trigger_scaling()
resources_allocated = autoscaler.get_total_resources()
# With delta=1:
# - Active bundles: 1 small (4 CPUs) - existing nodes, always included
# - Pending bundles: 1 small (4 CPUs) + 1 large (8 CPUs) - scale-up delta
# After capping to 10 CPUs:
# - Active: 4 CPUs (always included)
# - Sorted pending: [small (4), large (8)]
# - Add small: 4 + 4 = 8 CPUs ✓
# - Add large: 8 + 8 = 16 CPUs ✗ (exceeds limit)
# Result: 2 small bundles (8 CPUs)
# Ray autoscaler would see: need 2 small nodes, have 1 → spin up 1 more
assert resources_allocated.cpu == 8, (
f"Expected 8 CPUs (2 small node bundles), got {resources_allocated.cpu}. "
"Smaller bundles should be included even when larger ones exceed limits."
)
assert resources_allocated.gpu == 0
assert resources_allocated.memory == 4 * GiB
def test_try_scale_up_existing_nodes_prioritized_over_delta(self):
"""Test that existing node bundles are prioritized over scale-up delta bundles.
This tests a scenario where:
- Large existing node: 1 node at 6 CPUs (currently allocated)
- Small node type available: can add nodes at 2 CPUs each
- User limit: 8 CPUs
- Scale-up delta: 2 (want to add 2 small nodes)
The existing large node (6 CPUs) should always be included, and only
scale-up bundles that fit within the remaining budget should be added.
Without this prioritization, smaller scale-up bundles could crowd out
the representation of existing nodes.
"""
resource_limits = ExecutionResources.for_limits(cpu=8)
large_node_spec = _NodeResourceSpec.of(cpu=6, gpu=0, mem=3000)
small_node_spec = _NodeResourceSpec.of(cpu=2, gpu=0, mem=1000)
scale_up_threshold = 0.75
utilization = ExecutionResources(cpu=0.9, gpu=0.9, object_store_memory=0.9)
fake_coordinator = FakeAutoscalingCoordinator()
# Existing cluster: 1 large node (6 CPUs)
# Scale-up delta: 2 (want to add 2 of each node type)
def get_node_counts():
return {
large_node_spec: 1, # 1 existing large node (6 CPUs)
small_node_spec: 0, # 0 existing small nodes
}
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
resource_limits=resource_limits,
execution_id="test_execution_id",
cluster_scaling_up_delta=2,
resource_utilization_calculator=StubUtilizationGauge(utilization),
cluster_scaling_up_util_threshold=scale_up_threshold,
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=get_node_counts,
)
autoscaler.try_trigger_scaling()
resources_allocated = autoscaler.get_total_resources()
# Active bundles: 1 large (6 CPUs) - must be included
# Pending bundles: 2 large (12 CPUs) + 2 small (4 CPUs) = delta requests
# After capping to 8 CPUs:
# - Active: 6 CPUs (always included)
# - Remaining budget: 2 CPUs
# - Sorted pending: [small (2), small (2), large (6), large (6)]
# - Add small: 6 + 2 = 8 CPUs ✓
# - Add another small: 8 + 2 = 10 CPUs ✗
# Result: 1 large (active) + 1 small (delta) = 8 CPUs
assert resources_allocated.cpu == 8, (
f"Expected 8 CPUs (1 existing large + 1 delta small), got {resources_allocated.cpu}. "
"Existing node bundles should always be included before scale-up delta."
)
# Verify we have the large node's resources (it must be included)
assert resources_allocated.memory >= large_node_spec.mem, (
f"Existing large node (mem={large_node_spec.mem}) should be included. "
f"Got total memory={resources_allocated.memory}"
)
def test_try_scale_up_logs_info_message(self, propagate_logs, caplog):
fake_coordinator = FakeAutoscalingCoordinator()
node_spec = _NodeResourceSpec.of(cpu=1, gpu=0, mem=8 * GiB)
utilization = ExecutionResources(cpu=1, gpu=1, object_store_memory=1)
with patch(_IS_AUTOSCALING_ENABLED_PATH, return_value=True):
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
execution_id="test_execution_id",
resource_utilization_calculator=StubUtilizationGauge(utilization),
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {node_spec: 1},
)
with caplog.at_level(logging.INFO):
autoscaler.try_trigger_scaling()
expected_message = (
"The utilization of one or more logical resource is higher than the "
"specified threshold of 75%: CPU=100%, GPU=100%, memory=0%, "
"object_store_memory=100%. Requesting 1 node(s) of each shape: "
"[{CPU: 1, GPU: 0, memory: 8.0GiB}: 1 -> 2]"
)
log_messages = [record.message for record in caplog.records]
assert expected_message in log_messages, (
f"Expected log message not found.\n"
f"Expected: {expected_message}\n"
f"Actual logs: {log_messages}"
)
def test_nodes_with_similar_memory_grouped(self):
"""Test that nodes with slightly different memory are grouped together.
Nodes of the same type can report slightly different physical memory
(e.g. 14.85 GiB vs 14.94 GiB) due to non-deterministic physical memory
availability at Ray init time. They should produce the same spec.
"""
spec_a = _NodeResourceSpec.of(cpu=8, gpu=0, mem=int(14.87 * GiB))
spec_b = _NodeResourceSpec.of(cpu=8, gpu=0, mem=int(14.93 * GiB))
assert spec_a == spec_b
def test_debug_log_when_autoscaling_disabled(self, propagate_logs, caplog):
"""Test that autoscaling log is at DEBUG level when autoscaling is disabled."""
fake_coordinator = FakeAutoscalingCoordinator()
node_spec = _NodeResourceSpec.of(cpu=8, gpu=0, mem=1000)
utilization = ExecutionResources(cpu=1, gpu=0, object_store_memory=1)
with patch(_IS_AUTOSCALING_ENABLED_PATH, return_value=False):
autoscaler = DefaultClusterAutoscalerV2(
resource_manager=MagicMock(),
execution_id="test_execution_id",
resource_utilization_calculator=StubUtilizationGauge(utilization),
min_gap_between_autoscaling_requests_s=0,
autoscaling_coordinator=fake_coordinator,
get_node_counts=lambda: {node_spec: 2},
)
with caplog.at_level(logging.DEBUG):
autoscaler.try_trigger_scaling()
scaling_records = [r for r in caplog.records if "Requesting" in r.message]
assert len(scaling_records) == 1
assert scaling_records[0].levelno == logging.DEBUG
def test_v2_autoscaler_passes_label_selector_to_coordinator(monkeypatch):
"""``DefaultClusterAutoscalerV2`` forwards the DataContext's
``label_selector`` to the ``DefaultAutoscalingCoordinator`` it
constructs."""
from ray.data._internal.cluster_autoscaler import default_cluster_autoscaler_v2
captured = {}
class _StubProxy:
def __init__(self, *args, **kwargs):
captured.update(kwargs)
def request_resources(self, *args, **kwargs):
pass
monkeypatch.setattr(
default_cluster_autoscaler_v2, "DefaultAutoscalingCoordinator", _StubProxy
)
with patch(_IS_AUTOSCALING_ENABLED_PATH, return_value=False):
DefaultClusterAutoscalerV2(
resource_manager=Mock(),
execution_id="exec-1",
label_selector={"ray-subcluster": "training"},
)
assert captured["subcluster_selector"] == {"ray-subcluster": "training"}
def test_create_cluster_autoscaler_forwards_label_selector(monkeypatch):
"""The factory reads ``label_selector`` from ``execution_options`` and
forwards it to ``DefaultClusterAutoscalerV2``."""
captured = {}
class _StubV2:
def __init__(self, *args, **kwargs):
captured.update(kwargs)
monkeypatch.setattr(ca_pkg, "DefaultClusterAutoscalerV2", _StubV2)
data_context = Mock()
data_context.execution_options.resource_limits = Mock()
data_context.execution_options.label_selector = {"ray-subcluster": "training"}
create_cluster_autoscaler(
topology=Mock(),
resource_manager=Mock(),
data_context=data_context,
execution_id="exec-1",
)
assert captured["label_selector"] == {"ray-subcluster": "training"}
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))