-
Notifications
You must be signed in to change notification settings - Fork 8k
[Data] Add autoscaler_scales_up_when_compute_bound release test #65772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ac24c52
1ff8a4a
d38fb8e
78896ae
24596c3
a68523d
61582d3
3c22277
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| """Checks that the autoscaler scales up both worker groups when a pipeline is | ||
| compute bound on its CPU stage. | ||
|
|
||
| `produce` is a slow CPU stage and `consume` is a fast GPU stage holding one GPU | ||
| each. Saturating the GPU group isn't enough to keep the GPUs busy, so a healthy | ||
| autoscaler also has to add CPU nodes. The test asserts that the peak worker node | ||
| counts are exactly EXPECTED_GPU_NODES GPU nodes and at least MIN_CPU_NODES CPU | ||
| nodes. | ||
| """ | ||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CMIIW I don’t understand the 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 | ||
|
|
||
| # `consume` handles 2 blocks/s and `produce` emits 1 block/5s, so balancing needs | ||
| # 10 produce workers per consume worker: 10 GPU nodes x 10 = 100 producer CPUs. | ||
| # The GPU nodes supply 80, so the remaining 20 need ceil(20 / 8) = 3 CPU nodes. | ||
| _PRODUCE_WORKERS_PER_CONSUME_WORKER = ( | ||
| CONSUME_BATCH_SIZE / ROWS_PER_BLOCK / CONSUME_SLEEP_S | ||
| ) * PRODUCE_SLEEP_S | ||
| MIN_CPU_NODES = math.ceil( | ||
| ( | ||
| EXPECTED_GPU_NODES * _PRODUCE_WORKERS_PER_CONSUME_WORKER | ||
| - EXPECTED_GPU_NODES * CPUS_PER_NODE | ||
| ) | ||
| / CPUS_PER_NODE | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CPU threshold ignores head nodeMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 3c22277. Configure here. |
||
| assert MIN_CPU_NODES > 0, ( | ||
| "The GPU nodes' own CPUs already satisfy the pipeline, so this test no " | ||
| "longer exercises CPU scale-up. Adjust the sleeps or the compute config." | ||
| ) | ||
|
|
||
|
|
||
| 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]: | ||
| 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, num_cpus=0, batch_size=CONSUME_BATCH_SIZE) | ||
| ) | ||
| # Don't materialize, so blocks are freed as they're consumed. | ||
| for _ in ds.iter_internal_ref_bundles(): | ||
| pass | ||
|
|
||
| peak_cpu_nodes = monitor.get_peak_cpu_nodes() | ||
| peak_gpu_nodes = monitor.get_peak_gpu_nodes() | ||
| print(f"Peak worker nodes: {peak_cpu_nodes} CPU, {peak_gpu_nodes} GPU") | ||
|
|
||
| assert peak_gpu_nodes == EXPECTED_GPU_NODES, ( | ||
| f"Expected the autoscaler to provision {EXPECTED_GPU_NODES} GPU nodes, " | ||
| f"but it provisioned {peak_gpu_nodes}" | ||
| ) | ||
| assert peak_cpu_nodes >= 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_cpu_nodes}" | ||
| ) | ||
| if args.assert_max_cpu_nodes is not None: | ||
| assert peak_cpu_nodes <= 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_cpu_nodes}" | ||
| ) | ||
|
|
||
| return { | ||
| "peak_cpu_nodes": peak_cpu_nodes, | ||
| "peak_gpu_nodes": peak_gpu_nodes, | ||
| } | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| args = parse_args() | ||
| ray.init() | ||
|
|
||
| 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 |


There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
produceandconsumeand 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.