Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import argparse
import math
import time
from typing import Any, Dict, Iterator

import numpy as np
import pyarrow as pa

import ray

from benchmark import Benchmark
from cluster_resource_monitor import ClusterResourceMonitor

# With 1000 inputs this takes ~30 minutes, long enough that node provisioning

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we could move these test-specific values into the function and add a docstring explaining the setup and expected scaling behavior? That would make the test easier to follow without jumping between so many module-level constants.

Suggested change
# With 1000 inputs this takes ~30 minutes, long enough that node provisioning
def func(...):
"""
docstring
"""
num_inputs = 1000
blocks_per_input = 4
produce_sleep_s = 5
consume_sleep_s = 1
block_shape = (128, 1024, 1024)
consume_batch_size = 2 * block_shape[0]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the values are repetitively referenced in produce and consume and used in derivation, moving them to main() will raise the NameError. Therefore, I would rather keep them where they are and add the docstring at the top of the file.

@yuhuan130 yuhuan130 Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMIIW I don’t understand the NameError concern. I was imagining the pattern used in backpressure_benchmark.py: keepig the test configuration and calculation in main(), then pass the configuration to the UDFs with functools.partial. Or did you have another structure in mind?

For example:

def produce(_, *, blocks_per_input, sleep_s, block_shape):
    for _ in range(blocks_per_input):
        time.sleep(sleep_s)
        yield {"data": np.zeros(block_shape, dtype=np.uint8)}


def consume(batch, *, sleep_s):
    time.sleep(sleep_s)
    return batch


def main(args):
    num_inputs = 1000
    blocks_per_input = 4
    produce_sleep_s = 5
    consume_sleep_s = 1
    block_shape = (128, 1024, 1024)
    rows_per_block = block_shape[0]
    consume_batch_size = 2 * rows_per_block

    expected_gpu_nodes = 10
    cpus_per_node = 8

    # Each consumer processes 2 blocks/s. Each producer emits 0.2 blocks/s,
    # so one consumer needs 10 producers.
    blocks_per_consumer_batch = consume_batch_size / rows_per_block
    producers_per_consumer = (
        blocks_per_consumer_batch * produce_sleep_s / consume_sleep_s
    )
    
    # Ten consumers need 100 producer CPUs. The GPU nodes provide 80,
    # leaving a 20-CPU shortfall, or 3 CPU-only nodes.
    producer_cpus_needed = expected_gpu_nodes * producers_per_consumer
    producer_cpus_on_gpu_nodes = expected_gpu_nodes * cpus_per_node
    cpu_shortfall = producer_cpus_needed - producer_cpus_on_gpu_nodes
    min_cpu_nodes = math.ceil(cpu_shortfall / cpus_per_node)

    producer = functools.partial(
        produce,
        blocks_per_input=blocks_per_input,
        sleep_s=produce_sleep_s,
        block_shape=block_shape,
    )
    consumer = functools.partial(consume, sleep_s=consume_sleep_s)

# speed doesn't make the test flaky.
NUM_INPUTS = 1000
BLOCKS_PER_INPUT = 4
PRODUCE_SLEEP_S = 5
CONSUME_SLEEP_S = 1
BLOCK_SHAPE = (128, 1024, 1024)
ROWS_PER_BLOCK = BLOCK_SHAPE[0]
CONSUME_BATCH_SIZE = 2 * ROWS_PER_BLOCK

# From the compute config.
MAX_GPU_NODES = 10
CPUS_PER_NODE = 8

EXPECTED_GPU_NODES = MAX_GPU_NODES

# Calculation for MIN_CPU_NODES.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we simplify this calculation? There are many intermediate constants, which makes it hard to follow. For example, a short comment could explain that 10 GPU workers require 100 producer CPUs, the GPU nodes provide 80 CPUs, and the remaining 20 CPUs require 3 CPU-only nodes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd like to keep the derivation. With hardcoded values, changing any single constant means editing several lines and redoing the arithmetic by hand. I've collapsed the intermediates so the whole calculation reads in one place. Any thoughts about this?

_PRODUCE_BLOCKS_PER_S = 1 / PRODUCE_SLEEP_S
_BLOCKS_PER_CONSUME_BATCH = CONSUME_BATCH_SIZE / ROWS_PER_BLOCK
_CONSUME_BLOCKS_PER_S = _BLOCKS_PER_CONSUME_BATCH / CONSUME_SLEEP_S
_PRODUCE_WORKERS_PER_CONSUME_WORKER = _CONSUME_BLOCKS_PER_S / _PRODUCE_BLOCKS_PER_S

_CPUS_NEEDED = EXPECTED_GPU_NODES * _PRODUCE_WORKERS_PER_CONSUME_WORKER
_CPUS_FROM_GPU_NODES = EXPECTED_GPU_NODES * CPUS_PER_NODE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the CPU requirements are fully satisfied by the GPU nodes (i.e., _CPUS_NEEDED <= _CPUS_FROM_GPU_NODES), MIN_CPU_NODES can evaluate to a negative number. It is safer and more semantically correct to clamp this value to a minimum of 0 to prevent negative node count assertions.

Suggested change
_CPUS_FROM_GPU_NODES = EXPECTED_GPU_NODES * CPUS_PER_NODE
MIN_CPU_NODES = max(0, math.ceil((_CPUS_NEEDED - _CPUS_FROM_GPU_NODES) / CPUS_PER_NODE))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree. However, if that is the case, the test needs changes in sleeps or configs. Therefore, added assert to positive number and print(change numbers) when fails

MIN_CPU_NODES = math.ceil((_CPUS_NEEDED - _CPUS_FROM_GPU_NODES) / CPUS_PER_NODE)


def produce(_: Dict[str, np.ndarray]) -> Iterator[Dict[str, np.ndarray]]:
for _ in range(BLOCKS_PER_INPUT):
time.sleep(PRODUCE_SLEEP_S)
yield {"data": np.zeros(BLOCK_SHAPE, dtype=np.uint8)}


def consume(batch: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
time.sleep(CONSUME_SLEEP_S)
return batch


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"--assert-max-cpu-nodes",
type=int,
default=None,
help="Assert that the autoscaler provisions at most this many CPU nodes.",
)
return parser.parse_args()


def main(args: argparse.Namespace) -> Dict[str, Any]:
"""This test checks if the cluster scales up enough to balance the pipeline."""
if not ray.is_initialized():
ray.init()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this may be redundant. Benchmark.run_fn() API automatically initializes Ray.


input_blocks = [
pa.Table.from_pydict({"input_id": [input_id]}) for input_id in range(NUM_INPUTS)
]

with ClusterResourceMonitor() as monitor:
ds = (
ray.data.from_blocks(input_blocks)
.map_batches(produce)
.map_batches(consume, num_gpus=1, batch_size=CONSUME_BATCH_SIZE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we set num_cpus=0 here? My understanding is that the consumer otherwise reserves 1 CPU as well as 1 GPU. This leaves 7 CPUs per GPU node for the producer, so we would need 4 CPU nodes instead of 3. Setting num_cpus=0 would make the code match the calculation above.

)
# Don't materialize, so blocks are freed as they're consumed.
for _ in ds.iter_internal_ref_bundles():
pass

peak_nodes = monitor.get_peak_node_counts()
print(f"Peak worker nodes: {peak_nodes.cpu} CPU, {peak_nodes.gpu} GPU")

assert peak_nodes.gpu == EXPECTED_GPU_NODES, (
f"Expected the autoscaler to provision {EXPECTED_GPU_NODES} GPU nodes, "
f"but it provisioned {peak_nodes.gpu}"
)
assert peak_nodes.cpu >= MIN_CPU_NODES, (
f"Expected the autoscaler to provision at least {MIN_CPU_NODES} CPU nodes "
f"to balance the pipeline, but it provisioned {peak_nodes.cpu}"
)
if args.assert_max_cpu_nodes is not None:
assert peak_nodes.cpu <= args.assert_max_cpu_nodes, (
f"Expected the autoscaler to provision at most "
f"{args.assert_max_cpu_nodes} CPU nodes, but it provisioned "
f"{peak_nodes.cpu}"
)

return {
"peak_cpu_nodes": peak_nodes.cpu,
"peak_gpu_nodes": peak_nodes.gpu,
}


if __name__ == "__main__":
args = parse_args()

benchmark = Benchmark()
benchmark.run_fn("main", main, args)
benchmark.write_result()
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Two autoscaling worker groups that both start empty. The GPU group uses CPU
# instances each with a single fake GPU, so this test can exercise heterogeneous
# autoscaling without using real GPUs.
cloud: {{env["ANYSCALE_CLOUD_NAME"]}}

head_node:
instance_type: m5.2xlarge

worker_nodes:
# Runs the produce tasks.
- name: cpu-node
instance_type: m5.2xlarge
min_nodes: 0
max_nodes: 10

# Runs the consume tasks. Only `GPU` is overridden, so these nodes still
# expose the 8 vCPUs of an m5.2xlarge and can run produce tasks too. The
# test's CPU-node arithmetic depends on that.
- name: gpu-node
instance_type: m5.2xlarge
min_nodes: 0
max_nodes: 10
resources:
GPU: 1
44 changes: 41 additions & 3 deletions release/nightly_tests/dataset/cluster_resource_monitor.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
import time
import threading
from typing import Tuple, Optional
from typing import NamedTuple, Tuple, Optional

import ray
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
from ray.data._internal.execution.interfaces import ExecutionResources


class NodeCounts(NamedTuple):
"""The number of alive worker nodes, by whether they have a GPU."""

cpu: int
gpu: int


def _count_worker_nodes() -> NodeCounts:
"""Count the alive worker nodes, excluding the head node.

A node counts as a GPU node if it has any GPU resource.
"""
cpu_nodes = 0
gpu_nodes = 0
for node in ray.nodes():
if not node.get("Alive", False):
continue
resources = node.get("Resources", {})
if HEAD_NODE_RESOURCE_NAME in resources:
continue
if resources.get("GPU", 0) > 0:
gpu_nodes += 1
else:
cpu_nodes += 1
return NodeCounts(cpu=cpu_nodes, gpu=gpu_nodes)


class ClusterResourceMonitor:
"""Monitor and validate cluster resources during benchmark execution.

This class tracks the peak number of cluster resources during execution.

This can be used to validate that the autoscaler behaves well.
"""

Expand All @@ -23,6 +49,7 @@ def __init__(self):

self._peak_cpu_count: float = 0
self._peak_gpu_count: float = 0
self._peak_node_counts = NodeCounts(cpu=0, gpu=0)

def __repr__(self):
return "ClusterResourceMonitor()"
Expand All @@ -37,6 +64,10 @@ def __enter__(self):
def get_peak_cluster_resources(self) -> ExecutionResources:
return ExecutionResources(cpu=self._peak_cpu_count, gpu=self._peak_gpu_count)

def get_peak_node_counts(self) -> NodeCounts:
"""Get the peak number of alive worker nodes, excluding the head node."""
return self._peak_node_counts

def _start_background_thread(
self, interval_s: float = 5.0
) -> Tuple[threading.Thread, threading.Event]:
Expand All @@ -51,6 +82,13 @@ def monitor_cluster_resources():
self._peak_gpu_count = max(
self._peak_gpu_count, resources.get("GPU", 0)
)

node_counts = _count_worker_nodes()
self._peak_node_counts = NodeCounts(
cpu=max(self._peak_node_counts.cpu, node_counts.cpu),
gpu=max(self._peak_node_counts.gpu, node_counts.gpu),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The background monitoring thread calls _count_worker_nodes(), which queries GCS via ray.nodes(). If there are transient network or GCS RPC hiccups during this long-running test (~30 minutes), an unhandled exception will crash the daemon thread silently. Wrapping this call in a try-except block ensures the monitoring thread remains resilient and continues tracking resource metrics.

Suggested change
node_counts = _count_worker_nodes()
self._peak_node_counts = NodeCounts(
cpu=max(self._peak_node_counts.cpu, node_counts.cpu),
gpu=max(self._peak_node_counts.gpu, node_counts.gpu),
)
try:
node_counts = _count_worker_nodes()
self._peak_node_counts = NodeCounts(
cpu=max(self._peak_node_counts.cpu, node_counts.cpu),
gpu=max(self._peak_node_counts.gpu, node_counts.gpu),
)
except Exception as e:
import logging
logging.warning(f'Failed to count worker nodes: {e}')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but instead wrapped the whole loop for a consistency with ray.cluster_resources()


time.sleep(interval_s)

thread = threading.Thread(target=monitor_cluster_resources, daemon=True)
Expand Down
16 changes: 16 additions & 0 deletions release/release_data_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1317,3 +1317,19 @@
env: aws
cluster:
cluster_compute: dataset/cross_az_250_350_compute_aws.yaml

###################
# Autoscaling tests
###################

- name: autoscaler_scales_up_when_compute_bound
python: "3.10"
group: data-autoscaling

cluster:
anyscale_sdk_2026: true
cluster_compute: autoscaling/autoscaler_scales_up_when_compute_bound_cluster_compute.yaml

run:
timeout: 3600
script: python autoscaling/autoscaler_scales_up_when_compute_bound.py
Loading