Files
2026-07-13 13:17:40 +08:00

405 lines
14 KiB
Python

import sys
import time
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data.block import BlockMetadata
from ray.data.context import DataContext
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.data.tests.conftest import * # noqa
from ray.data.tests.conftest import (
CoreExecutionMetrics,
assert_core_execution_metrics_equals,
get_initial_core_execution_metrics_snapshot,
)
from ray.data.tests.util import extract_values
from ray.tests.conftest import * # noqa
def test_limit_execution(ray_start_regular):
last_snapshot = get_initial_core_execution_metrics_snapshot()
override_num_blocks = 20
ds = ray.data.range(100, override_num_blocks=override_num_blocks)
# Add some delay to the output to prevent all tasks from finishing
# immediately.
def delay(row):
time.sleep(0.1)
return row
ds = ds.map(delay)
last_snapshot = assert_core_execution_metrics_equals(
CoreExecutionMetrics(task_count={}),
last_snapshot=last_snapshot,
)
# During lazy execution, we should not execute too many more tasks than is
# needed to produce the requested number of rows.
for i in [1, 11]:
assert extract_values("id", ds.limit(i).take(200)) == list(range(i))
last_snapshot = assert_core_execution_metrics_equals(
CoreExecutionMetrics(
task_count={
"ReadRange->Map(delay)": lambda count: count
< override_num_blocks / 2,
"slice_fn": lambda count: count <= 1,
}
),
last_snapshot=last_snapshot,
)
# .materialize().limit() should only trigger execution once.
ds = ray.data.range(100, override_num_blocks=20).materialize()
last_snapshot = assert_core_execution_metrics_equals(
CoreExecutionMetrics(
task_count={
"ReadRange": 20,
}
),
last_snapshot=last_snapshot,
)
for i in [1, 10]:
assert extract_values("id", ds.limit(i).take(200)) == list(range(i))
assert_core_execution_metrics_equals(
CoreExecutionMetrics(task_count={"slice_fn": lambda count: count <= 1}),
last_snapshot=last_snapshot,
)
@pytest.mark.parametrize("lazy", [False, True])
def test_limit(ray_start_regular_shared, lazy):
ds = ray.data.range(100, override_num_blocks=20)
if not lazy:
ds = ds.materialize()
for i in range(100):
assert extract_values("id", ds.limit(i).take(200)) == list(range(i))
# NOTE: We test outside the power-of-2 range in order to ensure that we're not reading
# redundant files due to exponential ramp-up.
@pytest.mark.parametrize("limit", [10, 20, 30, 60])
def test_limit_no_redundant_read(
ray_start_regular_shared,
limit,
):
# Test that dataset truncation eliminates redundant reads.
@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, n):
def range_(i):
ray.get(self.counter.increment.remote())
return [
pd.DataFrame({"id": range(parallelism * i, parallelism * i + n)})
]
return [
ReadTask(
lambda i=i: range_(i),
BlockMetadata(
num_rows=n,
size_bytes=sum(
sys.getsizeof(i)
for i in range(parallelism * i, parallelism * i + n)
),
input_files=None,
exec_stats=None,
),
schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})),
)
for i in range(parallelism)
]
source = CountingRangeDatasource()
total_rows = 1000
override_num_blocks = 100
ds = ray.data.read_datasource(
source,
override_num_blocks=override_num_blocks,
n=total_rows // override_num_blocks,
)
# Apply multiple limit ops.
# Once the smallest limit is reached, the entire dataset should stop execution.
ds = ds.limit(total_rows)
ds = ds.limit(limit)
ds = ds.limit(total_rows)
# Check content.
assert len(ds.take(limit)) == limit
# Check number of read tasks launched.
# min_read_tasks is the minimum number of read tasks needed for the limit.
# We may launch more tasks than this number, in order to to maximize throughput.
# But the actual number of read tasks should be less than the parallelism.
count = ray.get(source.counter.get.remote())
min_read_tasks = limit // (total_rows // override_num_blocks)
assert min_read_tasks <= count < override_num_blocks
def test_limit_no_num_row_info(ray_start_regular_shared):
# Test that datasources with no number-of-rows metadata available are still able to
# be truncated, falling back to kicking off all read tasks.
class DumbOnesDatasource(Datasource):
def prepare_read(self, parallelism, n):
return parallelism * [
ReadTask(
lambda: [pd.DataFrame({"id": [1] * n})],
BlockMetadata(
num_rows=None,
size_bytes=sys.getsizeof(1) * n,
input_files=None,
exec_stats=None,
),
schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})),
)
]
ds = ray.data.read_datasource(DumbOnesDatasource(), override_num_blocks=10, n=10)
for i in range(1, 100):
assert extract_values("id", ds.limit(i).take(100)) == [1] * i
def test_per_task_row_limit_basic(ray_start_regular_shared, restore_data_context):
"""Test basic per-block limiting functionality."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
# Simple test that should work with the existing range datasource
ds = ray.data.range(1000, override_num_blocks=10).limit(50)
result = ds.take_all()
# Verify we get the correct results
assert len(result) == 50
assert [row["id"] for row in result] == list(range(50))
def test_per_task_row_limit_with_custom_readtask(ray_start_regular_shared):
"""Test per-block limiting directly with ReadTask implementation."""
def read_data_with_limit():
# This simulates a ReadTask that reads 200 rows
return [pd.DataFrame({"id": range(200)})]
# Create ReadTask with per-block limit
task_with_limit = ReadTask(
read_fn=read_data_with_limit,
metadata=BlockMetadata(
num_rows=200, size_bytes=1600, input_files=None, exec_stats=None
),
schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})),
per_task_row_limit=50,
)
# Execute the ReadTask
result_blocks = list(task_with_limit())
# Should get only 50 rows due to per-block limiting
assert len(result_blocks) == 1
assert len(result_blocks[0]) == 50
assert result_blocks[0]["id"].tolist() == list(range(50))
def test_per_task_row_limit_multiple_blocks_per_task(ray_start_regular_shared):
"""Test per-block limiting when ReadTasks return multiple blocks."""
def read_multiple_blocks_with_limit():
# This simulates a ReadTask that returns 3 blocks of 30 rows each
return [
pd.DataFrame({"id": range(0, 30)}),
pd.DataFrame({"id": range(30, 60)}),
pd.DataFrame({"id": range(60, 90)}),
]
# Create ReadTask with per-block limit of 70 (should get 2.33 blocks)
task = ReadTask(
read_fn=read_multiple_blocks_with_limit,
metadata=BlockMetadata(
num_rows=90, size_bytes=720, input_files=None, exec_stats=None
),
schema=pa.lib.Schema.from_pandas(pd.DataFrame({"id": []})),
per_task_row_limit=70,
)
result_blocks = list(task())
# Should get first 2 full blocks (60 rows) plus 10 rows from third block
total_rows = sum(len(block) for block in result_blocks)
assert total_rows == 70
# Verify the data is correct
all_ids = []
for block in result_blocks:
all_ids.extend(block["id"].tolist())
assert all_ids == list(range(70))
def test_per_task_row_limit_larger_than_data(
ray_start_regular_shared, restore_data_context
):
"""Test per-block limiting when limit is larger than available data."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
total_rows = 50
ds = ray.data.range(total_rows, override_num_blocks=5)
limited_ds = ds.limit(100) # Limit larger than data
result = limited_ds.take_all()
assert len(result) == total_rows
assert [row["id"] for row in result] == list(range(total_rows))
def test_per_task_row_limit_exact_block_boundary(
ray_start_regular_shared, restore_data_context
):
"""Test per-block limiting when limit exactly matches block boundaries."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
rows_per_block = 20
num_blocks = 5
limit = rows_per_block * 2 # Exactly 2 blocks
ds = ray.data.range(rows_per_block * num_blocks, override_num_blocks=num_blocks)
limited_ds = ds.limit(limit)
result = limited_ds.take_all()
assert len(result) == limit
assert [row["id"] for row in result] == list(range(limit))
@pytest.mark.parametrize("limit", [1, 5, 10, 25, 50, 99])
def test_per_task_row_limit_various_sizes(
ray_start_regular_shared, limit, restore_data_context
):
"""Test per-block limiting with various limit sizes."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
total_rows = 100
num_blocks = 10
ds = ray.data.range(total_rows, override_num_blocks=num_blocks)
limited_ds = ds.limit(limit)
result = limited_ds.take_all()
expected_len = min(limit, total_rows)
assert len(result) == expected_len
assert [row["id"] for row in result] == list(range(expected_len))
def test_per_task_row_limit_with_transformations(
ray_start_regular_shared, restore_data_context
):
"""Test that per-block limiting works correctly with transformations."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
# Test with map operation after limit
ds = ray.data.range(100, override_num_blocks=10)
limited_ds = ds.limit(20).map(lambda x: {"doubled": x["id"] * 2})
result = limited_ds.take_all()
assert len(result) == 20
assert [row["doubled"] for row in result] == [i * 2 for i in range(20)]
# Test with map operation before limit
ds = ray.data.range(100, override_num_blocks=10)
limited_ds = ds.map(lambda x: {"doubled": x["id"] * 2}).limit(20)
result = limited_ds.take_all()
assert len(result) == 20
assert [row["doubled"] for row in result] == [i * 2 for i in range(20)]
def test_per_task_row_limit_with_filter(ray_start_regular_shared, restore_data_context):
"""Test per-block limiting with filter operations."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
# Filter before limit - per-block limiting should still work at read level
ds = ray.data.range(200, override_num_blocks=10)
filtered_limited = ds.filter(lambda x: x["id"] % 2 == 0).limit(15)
result = filtered_limited.take_all()
assert len(result) == 15
# Should get first 15 even numbers
assert [row["id"] for row in result] == [i * 2 for i in range(15)]
def test_per_task_row_limit_readtask_properties(ray_start_regular_shared):
"""Test ReadTask per_block_limit property."""
def dummy_read():
return [pd.DataFrame({"id": [1, 2, 3]})]
# Test ReadTask without per_block_limit
task_no_limit = ReadTask(
read_fn=dummy_read,
metadata=BlockMetadata(
num_rows=3, size_bytes=24, input_files=None, exec_stats=None
),
)
assert task_no_limit.per_task_row_limit is None
# Test ReadTask with per_block_limit
task_with_limit = ReadTask(
read_fn=dummy_read,
metadata=BlockMetadata(
num_rows=3, size_bytes=24, input_files=None, exec_stats=None
),
per_task_row_limit=10,
)
assert task_with_limit.per_task_row_limit == 10
def test_per_task_row_limit_edge_cases(ray_start_regular_shared, restore_data_context):
"""Test per-block limiting edge cases."""
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
# Test with single row
ds = ray.data.range(1, override_num_blocks=1).limit(1)
result = ds.take_all()
assert len(result) == 1
assert result[0]["id"] == 0
# Test with limit of 1 on large dataset
ds = ray.data.range(10000, override_num_blocks=100).limit(1)
result = ds.take_all()
assert len(result) == 1
assert result[0]["id"] == 0
# Test with very large limit
ds = ray.data.range(100, override_num_blocks=10).limit(999999)
result = ds.take_all()
assert len(result) == 100
assert [row["id"] for row in result] == list(range(100))
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))