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

383 lines
13 KiB
Python

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__]))