import time from unittest.mock import patch import numpy as np import pandas as pd import pytest import ray from ray._private.internal_api import memory_summary from ray.data._internal.execution.backpressure_policy.downstream_capacity_backpressure_policy import ( DownstreamCapacityBackpressurePolicy, ) from ray.data._internal.execution.util import memory_string from ray.data._internal.util import MiB from ray.data.block import BlockMetadata from ray.data.datasource import Datasource, ReadTask from ray.data.tests.conftest import ( CoreExecutionMetrics, assert_core_execution_metrics_equals, get_initial_core_execution_metrics_snapshot, restore_data_context, # noqa: F401 ) from ray.tests.conftest import shutdown_only # noqa: F401 def test_large_e2e_backpressure_no_spilling( shutdown_only, restore_data_context # noqa: F811 ): """Test backpressure can prevent object spilling on a synthetic large-scale workload.""" # The cluster has 10 CPUs and 200MB object store memory. # # Each produce task generates 10 blocks, each of which has 10MB data. # In total, there will be 10 * 10 * 10MB = 1000MB intermediate data. # # `ReservationOpResourceAllocator` should dynamically allocate resources to each # operator and prevent object spilling. NUM_CPUS = 10 NUM_ROWS_PER_TASK = 10 NUM_TASKS = 20 NUM_ROWS_TOTAL = NUM_ROWS_PER_TASK * NUM_TASKS BLOCK_SIZE = 10 * MiB object_store_memory = 200 * MiB print(f">>> Setting Object Store to {memory_string(object_store_memory)}") ray.init(num_cpus=NUM_CPUS, object_store_memory=object_store_memory) def produce(batch): print(">>> [Producer] Produce task started", batch["id"]) time.sleep(0.1) for id in batch["id"]: print(f">>> [Producer] Producing row {id=}") yield { "id": [id], "image": [np.zeros(BLOCK_SIZE, dtype=np.uint8)], } def consume(batch): print(">>> [Consumer] Consume task started", batch["id"]) time.sleep(0.01) return {"id": batch["id"], "result": [0 for _ in batch["id"]]} data_context = ray.data.DataContext.get_current() data_context.execution_options.verbose_progress = True data_context.target_max_block_size = BLOCK_SIZE last_snapshot = get_initial_core_execution_metrics_snapshot() ds = ray.data.range(NUM_ROWS_TOTAL, override_num_blocks=NUM_TASKS) ds = ds.map_batches(produce, batch_size=NUM_ROWS_PER_TASK) ds = ds.map_batches(consume, batch_size=None, num_cpus=0.9) # Check core execution metrics every 10 rows, because it's expensive. for _ in ds.iter_batches(batch_size=NUM_ROWS_PER_TASK): last_snapshot = assert_core_execution_metrics_equals( CoreExecutionMetrics( object_store_stats={ "spilled_bytes_total": 0, "restored_bytes_total": 0, }, ), last_snapshot, ) def _build_dataset( obj_store_limit, producer_num_cpus, consumer_num_cpus, num_blocks, block_size, insert_limit_op=False, ): # Create a dataset with 2 operators: # - The producer op has only 1 task, which produces `num_blocks` blocks, each # of which has `block_size` data. # - The consumer op has `num_blocks` tasks, each of which consumes 1 block. ctx = ray.data.DataContext.get_current() ctx.target_max_block_size = block_size ctx.execution_options.resource_limits = ctx.execution_options.resource_limits.copy( object_store_memory=obj_store_limit ) def producer(batch): for i in range(num_blocks): print(f"[{time.time()}] Producing block #{i} ({block_size=})") yield { "id": [i], "data": [np.zeros(block_size, dtype=np.uint8)], } def consumer(batch): assert len(batch["id"]) == 1 print(f"[{time.time()}] Consuming block #{batch['id'][0]}") time.sleep(0.01) del batch["data"] return batch ds = ray.data.range(1, override_num_blocks=1).materialize() ds = ds.map_batches(producer, batch_size=None, num_cpus=producer_num_cpus) # Add a limit op in the middle, to test that ReservationOpResourceAllocator # will account limit op's resource usage to the previous producer map op. if insert_limit_op: ds = ds.limit(num_blocks) ds = ds.map_batches(consumer, batch_size=None, num_cpus=consumer_num_cpus) if insert_limit_op: ds = ds.limit(num_blocks) return ds @pytest.mark.parametrize( "cluster_cpus, cluster_obj_store_mem_mb", [ (3, 500), # CPU not enough (4, 100), # Object store memory not enough (3, 100), # Both not enough ], ) @pytest.mark.parametrize("insert_limit_op", [False, True]) def test_no_deadlock_on_small_cluster_resources( cluster_cpus, cluster_obj_store_mem_mb, insert_limit_op, shutdown_only, # noqa: F811 restore_data_context, # noqa: F811 ): """Test when cluster resources are not enough for launching one task per op, the execution can still proceed without deadlock. """ cluster_obj_store_mem_mb *= 1024**2 ray.init(num_cpus=cluster_cpus, object_store_memory=cluster_obj_store_mem_mb) num_blocks = 10 block_size = 100 * 1024 * 1024 ds = _build_dataset( obj_store_limit=cluster_obj_store_mem_mb // 2, producer_num_cpus=3, consumer_num_cpus=1, num_blocks=num_blocks, block_size=block_size, insert_limit_op=insert_limit_op, ) assert len(ds.take_all()) == num_blocks @pytest.mark.parametrize("insert_limit_op", [False, True]) def test_no_deadlock_on_resource_contention( insert_limit_op, shutdown_only, restore_data_context # noqa: F811 ): """Test when resources are preempted by non-Data code, the execution can still proceed without deadlock.""" cluster_obj_store_mem = 1000 * 1024 * 1024 ray.init(num_cpus=5, object_store_memory=cluster_obj_store_mem) # Create a non-Data actor that uses 4 CPUs, only 1 CPU # is left for Data. Currently Data StreamExecutor still # incorrectly assumes it has all the 5 CPUs. # Check that we don't deadlock in this case. @ray.remote(num_cpus=4) class DummyActor: def foo(self): return None dummy_actor = DummyActor.remote() ray.get(dummy_actor.foo.remote()) num_blocks = 10 block_size = 50 * 1024 * 1024 ds = _build_dataset( obj_store_limit=cluster_obj_store_mem // 2, producer_num_cpus=1, consumer_num_cpus=0.9, num_blocks=num_blocks, block_size=block_size, insert_limit_op=insert_limit_op, ) from ray.data._internal.execution.streaming_executor_state import IdleDetector with patch.object(IdleDetector, "DETECTION_INTERVAL_S", 0.1): assert len(ds.take_all()) == num_blocks def test_no_deadlock_when_downstream_capacity_policy_zeros_limit( shutdown_only, restore_data_context # noqa: F811 ): """Test when DownstreamCapacityBackpressurePolicy zeros the output limit, the execution can still proceed without deadlock.""" cluster_obj_store_mem = 100 * MiB ray.init(num_cpus=2, object_store_memory=cluster_obj_store_mem) num_blocks = 20 block_size = 1 * MiB ds = _build_dataset( obj_store_limit=cluster_obj_store_mem // 2, producer_num_cpus=1, consumer_num_cpus=1, num_blocks=num_blocks, block_size=block_size, ) # Force DownstreamCapacityBackpressurePolicy to always return 0 to trigger unblock with patch.object( DownstreamCapacityBackpressurePolicy, "max_task_output_bytes_to_read", lambda self, op: 0, ): # Without the escape hatch firing, this would hang. assert len(ds.take_all()) == num_blocks def test_no_deadlock_with_preserve_order( restore_data_context, shutdown_only # noqa: F811 ): """Test backpressure won't cause deadlocks when `preserve_order=True`.""" num_blocks = 20 block_size = 10 * 1024 * 1024 ray.init(num_cpus=num_blocks) data_context = ray.data.DataContext.get_current() data_context.target_max_block_size = block_size data_context._max_num_blocks_in_streaming_gen_buffer = 1 data_context.execution_options.preserve_order = True data_context.execution_options.resource_limits = ( data_context.execution_options.resource_limits.copy( object_store_memory=5 * block_size ) ) # Some tasks are slower than others. # The faster tasks will finish first and occupy Map op's internal output buffer. # Test that we won't backpressure the operator in this case. def map_fn(batch): idx = batch["id"][0] print("map_fn", idx, time.time()) if idx % 2 == 0: time.sleep(3) batch["data"] = [np.zeros(block_size, dtype=np.uint8)] return batch ds = ray.data.range(num_blocks, override_num_blocks=num_blocks) ds = ds.map_batches(map_fn, batch_size=None, num_cpus=1) assert len(ds.take_all()) == num_blocks def test_input_backpressure_e2e(restore_data_context, shutdown_only): # noqa: F811 # Tests that backpressure applies even when reading directly from the input # datasource. This relies on datasource metadata size estimation. @ray.remote class Counter: def __init__(self): self.count = 0 def increment(self): self.count += 1 def get(self): return self.count def reset(self): self.count = 0 class CountingRangeDatasource(Datasource): def __init__(self): self.counter = Counter.remote() def prepare_read(self, parallelism): # Use 50 MiB blocks to exceed the 25 MiB output reservation # and trigger object store backpressure num_bytes = 50 * MiB def range_(i): print(f">>> Read task: {i=}") ray.get(self.counter.increment.remote()) return [pd.DataFrame({"data": np.ones((num_bytes,), dtype=np.uint8)})] print(f">>> Block size: {num_bytes}") return [ ReadTask( lambda i=i: range_(i), BlockMetadata( num_rows=1, size_bytes=num_bytes, input_files=None, exec_stats=None, ), ) for i in range(parallelism) ] source = CountingRangeDatasource() ctx = ray.data.DataContext.get_current() ctx.execution_options.resource_limits = ctx.execution_options.resource_limits.copy( object_store_memory=100 * MiB, cpu=1, ) ctx.target_max_block_size = 50 * MiB # Create dataset with many blocks ds = ray.data.read_datasource(source, override_num_blocks=1000) it = iter(ds.iter_internal_ref_bundles()) # Dequeue 1 block next(it) # Let it bake for some time time.sleep(3) launched = ray.get(source.counter.get.remote()) # Clean up del it # With 50 MiB blocks and 100 MiB limit, backpressure should limit to ~2 tasks # because after 2 outputs (100 MiB), the budget is depleted assert launched == 2, launched def test_streaming_backpressure_e2e( shutdown_only, monkeypatch, restore_data_context # noqa: F811 ): # This test case is particularly challenging since there is a large input->output # increase in data size: https://github.com/ray-project/ray/issues/34041 # Increase the Ray Core spilling threshold to 100% to avoid flakiness. monkeypatch.setenv("RAY_object_spilling_threshold", "1") class TestSlow: def __call__(self, df: np.ndarray): time.sleep(2) return {"id": np.random.randn(1, 20, 1024, 1024)} class TestFast: def __call__(self, df: np.ndarray): time.sleep(0.5) return {"id": np.random.randn(1, 20, 1024, 1024)} ctx = ray.init(object_store_memory=4e9) ds = ray.data.range_tensor(20, shape=(3, 1024, 1024), override_num_blocks=20) pipe = ds.map_batches( TestFast, batch_size=1, num_cpus=0.5, compute=ray.data.ActorPoolStrategy(size=2), ).map_batches( TestSlow, batch_size=1, compute=ray.data.ActorPoolStrategy(size=1), ) for batch in pipe.iter_batches(batch_size=1, prefetch_batches=2): ... # If backpressure is not working right, we will spill. meminfo = memory_summary(ctx.address_info["address"], stats_only=True) assert "Spilled" not in meminfo, meminfo if __name__ == "__main__": import sys sys.exit(pytest.main(["-sv", __file__]))