449 lines
16 KiB
Python
449 lines
16 KiB
Python
import itertools
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
import pandas as pd
|
|
import pyarrow as pa
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data._internal.compute import TaskPoolStrategy
|
|
from ray.data._internal.datasource.parquet_datasink import ParquetDatasink
|
|
from ray.data._internal.execution.interfaces.op_runtime_metrics import OpRuntimeMetrics
|
|
from ray.data._internal.execution.operators.base_physical_operator import (
|
|
AllToAllOperator,
|
|
)
|
|
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
|
|
from ray.data._internal.execution.operators.map_operator import MapOperator
|
|
from ray.data._internal.execution.operators.task_pool_map_operator import (
|
|
TaskPoolMapOperator,
|
|
)
|
|
from ray.data._internal.execution.operators.zip_operator import ZipOperator
|
|
from ray.data._internal.logical.interfaces import LogicalPlan
|
|
from ray.data._internal.logical.interfaces.physical_plan import PhysicalPlan
|
|
from ray.data._internal.logical.operators import (
|
|
RandomShuffle,
|
|
Repartition,
|
|
Sort,
|
|
)
|
|
from ray.data._internal.logical.operators.n_ary_operator import Zip
|
|
from ray.data._internal.logical.operators.write_operator import Write
|
|
from ray.data._internal.logical.rules import (
|
|
ConfigureMapTaskMemoryUsingOutputSize,
|
|
)
|
|
from ray.data._internal.planner import create_planner
|
|
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
|
|
from ray.data._internal.random_config import RandomSeedConfig
|
|
from ray.data._internal.stats import DatasetStats
|
|
from ray.data.context import DataContext
|
|
from ray.data.tests.conftest import * # noqa
|
|
from ray.data.tests.test_util import _check_usage_record, get_parquet_read_logical_op
|
|
from ray.data.tests.util import column_udf, extract_values, named_values
|
|
from ray.tests.conftest import * # noqa
|
|
|
|
|
|
def test_random_shuffle_operator(ray_start_regular_shared_2_cpus):
|
|
ctx = DataContext.get_current()
|
|
|
|
planner = create_planner()
|
|
read_op = get_parquet_read_logical_op()
|
|
op = RandomShuffle(
|
|
seed_config=RandomSeedConfig(seed=0),
|
|
input_dependencies=[read_op],
|
|
)
|
|
plan = LogicalPlan(op, ctx)
|
|
physical_plan, _ = planner.plan(plan)
|
|
physical_op = physical_plan.dag
|
|
|
|
assert op.name == "RandomShuffle"
|
|
assert isinstance(physical_op, AllToAllOperator)
|
|
assert len(physical_op.input_dependencies) == 1
|
|
assert isinstance(physical_op.input_dependencies[0], MapOperator)
|
|
|
|
# Check that the linked logical operator is the same the input op.
|
|
assert physical_op._logical_operators == [op]
|
|
|
|
|
|
def test_random_shuffle_e2e(ray_start_regular_shared_2_cpus, configure_shuffle_method):
|
|
ds = ray.data.range(12, override_num_blocks=4)
|
|
r1 = extract_values("id", ds.random_shuffle(seed=0).take_all())
|
|
r2 = extract_values("id", ds.random_shuffle(seed=1024).take_all())
|
|
assert r1 != r2, (r1, r2)
|
|
assert sorted(r1) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], r1
|
|
assert sorted(r2) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], r2
|
|
_check_usage_record(["ReadRange", "RandomShuffle"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"shuffle",
|
|
[True, False],
|
|
)
|
|
def test_repartition_operator(ray_start_regular_shared_2_cpus, shuffle):
|
|
ctx = DataContext.get_current()
|
|
|
|
planner = create_planner()
|
|
read_op = get_parquet_read_logical_op()
|
|
op = Repartition(num_outputs=5, shuffle=shuffle, input_dependencies=[read_op])
|
|
plan = LogicalPlan(op, ctx)
|
|
physical_plan, _ = planner.plan(plan)
|
|
physical_op = physical_plan.dag
|
|
|
|
assert op.name == "Repartition"
|
|
assert isinstance(physical_op, AllToAllOperator)
|
|
assert len(physical_op.input_dependencies) == 1
|
|
assert isinstance(physical_op.input_dependencies[0], MapOperator)
|
|
|
|
# Check that the linked logical operator is the same the input op.
|
|
assert physical_op._logical_operators == [op]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"shuffle",
|
|
[True, False],
|
|
)
|
|
def test_repartition_e2e(
|
|
ray_start_regular_shared_2_cpus, configure_shuffle_method, shuffle
|
|
):
|
|
def _check_repartition_usage_and_stats(ds):
|
|
_check_usage_record(["ReadRange", "Repartition"])
|
|
ds_stats: DatasetStats = ds._raw_stats()
|
|
if shuffle:
|
|
assert ds_stats.base_name == "ReadRange->Repartition"
|
|
assert "ReadRange->RepartitionMap" in ds_stats.metadata
|
|
else:
|
|
assert ds_stats.base_name == "Repartition"
|
|
assert "RepartitionSplit" in ds_stats.metadata
|
|
assert "RepartitionReduce" in ds_stats.metadata
|
|
|
|
ds = ray.data.range(10000, override_num_blocks=10).repartition(20, shuffle=shuffle)
|
|
assert (
|
|
ds._logical_plan.initial_num_blocks() == 20
|
|
), ds._logical_plan.initial_num_blocks()
|
|
assert ds.sum() == sum(range(10000))
|
|
assert ds._block_num_rows() == [500] * 20, ds._block_num_rows()
|
|
_check_repartition_usage_and_stats(ds)
|
|
|
|
# Test num_output_blocks > num_rows to trigger empty block handling.
|
|
ds = ray.data.range(20, override_num_blocks=10).repartition(40, shuffle=shuffle)
|
|
assert (
|
|
ds._logical_plan.initial_num_blocks() == 40
|
|
), ds._logical_plan.initial_num_blocks()
|
|
assert ds.sum() == sum(range(20))
|
|
if shuffle:
|
|
assert ds._block_num_rows() == [10] * 2 + [0] * (40 - 2), ds._block_num_rows()
|
|
else:
|
|
assert ds._block_num_rows() == [1] * 20 + [0] * 20, ds._block_num_rows()
|
|
_check_repartition_usage_and_stats(ds)
|
|
|
|
# Test case where number of rows does not divide equally into num_output_blocks.
|
|
ds = ray.data.range(22).repartition(4, shuffle=shuffle)
|
|
assert (
|
|
ds._logical_plan.initial_num_blocks() == 4
|
|
), ds._logical_plan.initial_num_blocks()
|
|
assert ds.sum() == sum(range(22))
|
|
if shuffle:
|
|
assert ds._block_num_rows() == [9, 9, 4, 0], ds._block_num_rows()
|
|
else:
|
|
assert ds._block_num_rows() == [5, 6, 5, 6], ds._block_num_rows()
|
|
_check_repartition_usage_and_stats(ds)
|
|
|
|
# Test case where we do not split on repartitioning.
|
|
ds = ray.data.range(10, override_num_blocks=1).repartition(1, shuffle=shuffle)
|
|
assert (
|
|
ds._logical_plan.initial_num_blocks() == 1
|
|
), ds._logical_plan.initial_num_blocks()
|
|
assert ds.sum() == sum(range(10))
|
|
assert ds._block_num_rows() == [10], ds._block_num_rows()
|
|
_check_repartition_usage_and_stats(ds)
|
|
|
|
|
|
def test_write_operator(ray_start_regular_shared_2_cpus, tmp_path):
|
|
ctx = DataContext.get_current()
|
|
|
|
concurrency = 2
|
|
planner = create_planner()
|
|
datasink = ParquetDatasink(tmp_path)
|
|
read_op = get_parquet_read_logical_op()
|
|
op = Write(
|
|
datasink,
|
|
input_dependencies=[read_op],
|
|
compute=TaskPoolStrategy(concurrency),
|
|
)
|
|
plan = LogicalPlan(op, ctx)
|
|
physical_plan, _ = planner.plan(plan)
|
|
physical_op = physical_plan.dag
|
|
|
|
assert op.name == "Write"
|
|
assert isinstance(physical_op, TaskPoolMapOperator)
|
|
assert physical_op._max_concurrency == concurrency
|
|
assert len(physical_op.input_dependencies) == 1
|
|
assert isinstance(physical_op.input_dependencies[0], MapOperator)
|
|
|
|
# Check that the linked logical operator is the same the input op.
|
|
assert physical_op._logical_operators == [op]
|
|
|
|
|
|
def test_sort_operator(
|
|
ray_start_regular_shared_2_cpus,
|
|
):
|
|
ctx = DataContext.get_current()
|
|
|
|
planner = create_planner()
|
|
read_op = get_parquet_read_logical_op()
|
|
op = Sort(
|
|
sort_key=SortKey("col1"),
|
|
input_dependencies=[read_op],
|
|
)
|
|
plan = LogicalPlan(op, ctx)
|
|
physical_plan, _ = planner.plan(plan)
|
|
physical_op = physical_plan.dag
|
|
|
|
assert op.name == "Sort"
|
|
assert isinstance(physical_op, AllToAllOperator)
|
|
assert len(physical_op.input_dependencies) == 1
|
|
assert isinstance(physical_op.input_dependencies[0], MapOperator)
|
|
|
|
|
|
def test_sort_e2e(ray_start_regular_shared_2_cpus, configure_shuffle_method, tmp_path):
|
|
ds = ray.data.range(100, override_num_blocks=4)
|
|
ds = ds.random_shuffle()
|
|
ds = ds.sort("id")
|
|
assert extract_values("id", ds.take_all()) == list(range(100))
|
|
_check_usage_record(["ReadRange", "RandomShuffle", "Sort"])
|
|
|
|
df = pd.DataFrame({"one": list(range(100)), "two": ["a"] * 100})
|
|
ds = ray.data.from_pandas([df])
|
|
ds.write_parquet(tmp_path)
|
|
|
|
ds = ray.data.read_parquet(tmp_path)
|
|
ds = ds.random_shuffle()
|
|
ds1 = ds.sort("one")
|
|
ds2 = ds.sort("one", descending=True)
|
|
r1 = ds1.select_columns(["one"]).take_all()
|
|
r2 = ds2.select_columns(["one"]).take_all()
|
|
assert [d["one"] for d in r1] == list(range(100))
|
|
assert [d["one"] for d in r2] == list(reversed(range(100)))
|
|
|
|
|
|
def test_sort_validate_keys(ray_start_regular_shared_2_cpus):
|
|
ds = ray.data.range(10)
|
|
assert extract_values("id", ds.sort("id").take_all()) == list(range(10))
|
|
|
|
invalid_col_name = "invalid_column"
|
|
with pytest.raises(ValueError, match="there's no such column in the dataset"):
|
|
ds.sort(invalid_col_name).take_all()
|
|
|
|
ds_named = ray.data.from_items(
|
|
[
|
|
{"col1": 1, "col2": 2},
|
|
{"col1": 3, "col2": 4},
|
|
{"col1": 5, "col2": 6},
|
|
{"col1": 7, "col2": 8},
|
|
]
|
|
)
|
|
|
|
ds_sorted_col1 = ds_named.sort("col1", descending=True)
|
|
r1 = ds_sorted_col1.select_columns(["col1"]).take_all()
|
|
r2 = ds_sorted_col1.select_columns(["col2"]).take_all()
|
|
assert [d["col1"] for d in r1] == [7, 5, 3, 1]
|
|
assert [d["col2"] for d in r2] == [8, 6, 4, 2]
|
|
|
|
with pytest.raises(ValueError, match="there's no such column in the dataset"):
|
|
ds_named.sort(invalid_col_name).take_all()
|
|
|
|
|
|
def test_aggregate_e2e(ray_start_regular_shared_2_cpus, configure_shuffle_method):
|
|
ds = ray.data.range(100, override_num_blocks=4)
|
|
ds = ds.groupby("id").count()
|
|
assert ds.count() == 100
|
|
for idx, row in enumerate(ds.sort("id").iter_rows()):
|
|
assert row == {"id": idx, "count()": 1}
|
|
_check_usage_record(["ReadRange", "Aggregate"])
|
|
|
|
|
|
def test_aggregate_validate_keys(ray_start_regular_shared_2_cpus):
|
|
ds = ray.data.range(10)
|
|
invalid_col_name = "invalid_column"
|
|
with pytest.raises(ValueError):
|
|
ds.groupby(invalid_col_name).count()
|
|
|
|
ds_named = ray.data.from_items(
|
|
[
|
|
{"col1": 1, "col2": "a"},
|
|
{"col1": 1, "col2": "b"},
|
|
{"col1": 2, "col2": "c"},
|
|
{"col1": 3, "col2": "c"},
|
|
]
|
|
)
|
|
|
|
ds_groupby_col1 = ds_named.groupby("col1").count()
|
|
assert ds_groupby_col1.sort("col1").take_all() == [
|
|
{"col1": 1, "count()": 2},
|
|
{"col1": 2, "count()": 1},
|
|
{"col1": 3, "count()": 1},
|
|
]
|
|
ds_groupby_col2 = ds_named.groupby("col2").count()
|
|
assert ds_groupby_col2.sort("col2").take_all() == [
|
|
{"col2": "a", "count()": 1},
|
|
{"col2": "b", "count()": 1},
|
|
{"col2": "c", "count()": 2},
|
|
]
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="there's no such column in the dataset",
|
|
):
|
|
ds_named.groupby(invalid_col_name).count()
|
|
|
|
|
|
def test_zip_operator(ray_start_regular_shared_2_cpus):
|
|
ctx = DataContext.get_current()
|
|
|
|
planner = create_planner()
|
|
read_op1 = get_parquet_read_logical_op()
|
|
read_op2 = get_parquet_read_logical_op()
|
|
op = Zip([read_op1, read_op2])
|
|
plan = LogicalPlan(op, ctx)
|
|
physical_plan, _ = planner.plan(plan)
|
|
physical_op = physical_plan.dag
|
|
|
|
assert op.name == "Zip"
|
|
assert isinstance(physical_op, ZipOperator)
|
|
assert len(physical_op.input_dependencies) == 2
|
|
assert isinstance(physical_op.input_dependencies[0], MapOperator)
|
|
assert isinstance(physical_op.input_dependencies[1], MapOperator)
|
|
|
|
# Check that the linked logical operator is the same the input op.
|
|
assert physical_op._logical_operators == [op]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"num_blocks1,num_blocks2,num_blocks3",
|
|
list(itertools.combinations_with_replacement(range(1, 4), 3)),
|
|
)
|
|
def test_zip_e2e(
|
|
ray_start_regular_shared_2_cpus, num_blocks1, num_blocks2, num_blocks3
|
|
):
|
|
n = 4
|
|
ds1 = ray.data.range(n, override_num_blocks=num_blocks1)
|
|
ds2 = ray.data.range(n, override_num_blocks=num_blocks2).map(
|
|
column_udf("id", lambda x: x + 1)
|
|
)
|
|
ds3 = ray.data.range(n, override_num_blocks=num_blocks3).map(
|
|
column_udf("id", lambda x: x + 2)
|
|
)
|
|
ds = ds1.zip(ds2, ds3)
|
|
assert ds.take() == named_values(
|
|
["id", "id_1", "id_2"], zip(range(n), range(1, n + 1), range(2, n + 2))
|
|
)
|
|
_check_usage_record(["ReadRange", "Zip"])
|
|
|
|
|
|
def test_execute_to_legacy_block_list(
|
|
ray_start_regular_shared_2_cpus,
|
|
):
|
|
ds = ray.data.range(10)
|
|
# Stats not initialized until `ds.iter_rows()` is called
|
|
assert ds._cache.get_stats() is None
|
|
|
|
for i, row in enumerate(ds.iter_rows()):
|
|
assert row["id"] == i
|
|
|
|
stats = ds._cache.get_stats()
|
|
assert stats is not None
|
|
assert "ReadRange" in stats.metadata
|
|
assert stats.time_total_s > 0
|
|
|
|
|
|
def test_streaming_executor(
|
|
ray_start_regular_shared_2_cpus,
|
|
):
|
|
ds = ray.data.range(100, override_num_blocks=4)
|
|
ds = ds.map_batches(lambda x: x)
|
|
ds = ds.filter(lambda x: x["id"] > 0)
|
|
ds = ds.random_shuffle()
|
|
ds = ds.map_batches(lambda x: x)
|
|
|
|
result = []
|
|
for batch in ds.iter_batches(batch_size=3):
|
|
batch = batch["id"]
|
|
assert len(batch) == 3, batch
|
|
result.extend(batch)
|
|
assert sorted(result) == list(range(1, 100)), result
|
|
_check_usage_record(["ReadRange", "MapBatches", "Filter", "RandomShuffle"])
|
|
|
|
|
|
def test_schema_partial_execution(
|
|
ray_start_regular_shared_2_cpus,
|
|
):
|
|
fields = [
|
|
("sepal.length", pa.float64()),
|
|
("sepal.width", pa.float64()),
|
|
("petal.length", pa.float64()),
|
|
("petal.width", pa.float64()),
|
|
("variety", pa.string()),
|
|
]
|
|
ds = ray.data.read_parquet(
|
|
"example://iris.parquet",
|
|
schema=pa.schema(fields),
|
|
override_num_blocks=2,
|
|
).map_batches(lambda x: x)
|
|
|
|
iris_schema = ds.schema()
|
|
assert iris_schema == ray.data.dataset.Schema(pa.schema(fields))
|
|
# Verify that ds.schema() executes only the first block, and not the
|
|
# entire Dataset.
|
|
assert not ds._has_computed_output()
|
|
if ray.data.DataContext.get_current().use_datasource_v2:
|
|
assert ds._logical_plan.dag.dag_str == (
|
|
"ListFiles[ListFiles] -> ReadFiles[ReadFilesParquetV2] -> "
|
|
"MapBatches[MapBatches(<lambda>)]"
|
|
)
|
|
else:
|
|
assert ds._logical_plan.dag.dag_str == (
|
|
"Read[ReadParquet] -> MapBatches[MapBatches(<lambda>)]"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"average_bytes_per_output, ray_remote_args, ray_remote_args_fn, data_context, expected_memory",
|
|
[
|
|
# The user hasn't set memory, so the rule should configure it.
|
|
(1, None, None, DataContext(), 1),
|
|
# The user has set memory, so the rule shouldn't change it.
|
|
(1, {"memory": 2}, None, DataContext(), 2),
|
|
(1, None, lambda: {"memory": 2}, DataContext(), 2),
|
|
# An estimate isn't available, so the rule shouldn't configure memory.
|
|
(None, None, None, DataContext(), None),
|
|
],
|
|
)
|
|
def test_configure_map_task_memory_rule(
|
|
average_bytes_per_output,
|
|
ray_remote_args,
|
|
ray_remote_args_fn,
|
|
data_context,
|
|
expected_memory,
|
|
):
|
|
input_op = InputDataBuffer(MagicMock(), [])
|
|
map_op = MapOperator.create(
|
|
MagicMock(),
|
|
input_op=input_op,
|
|
data_context=data_context,
|
|
ray_remote_args=ray_remote_args,
|
|
ray_remote_args_fn=ray_remote_args_fn,
|
|
)
|
|
map_op._metrics = MagicMock(
|
|
spec=OpRuntimeMetrics, average_bytes_per_output=average_bytes_per_output
|
|
)
|
|
plan = PhysicalPlan(map_op, op_map=MagicMock(), context=data_context)
|
|
rule = ConfigureMapTaskMemoryUsingOutputSize()
|
|
|
|
new_plan = rule.apply(plan)
|
|
|
|
remote_args = new_plan.dag._get_dynamic_ray_remote_args()
|
|
assert remote_args.get("memory") == expected_memory
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", __file__]))
|