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

1070 lines
38 KiB
Python

from unittest.mock import MagicMock
import numpy as np
import pandas as pd
import pytest
import ray
from ray.data._internal.execution.bundle_queue import EstimateSize
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.map_transformer import (
BatchMapTransformFn,
BlockMapTransformFn,
)
from ray.data._internal.logical.interfaces import LogicalPlan
from ray.data._internal.logical.operators import (
Filter,
FlatMap,
InputData,
MapBatches,
MapRows,
Project,
Read,
Write,
)
from ray.data._internal.logical.optimizers import PhysicalOptimizer, get_execution_plan
from ray.data._internal.planner import create_planner
from ray.data._internal.stats import DatasetStats
from ray.data._internal.util import rows_same
from ray.data.context import DataContext, ShuffleStrategy
from ray.data.dataset import Dataset
from ray.data.expressions import star
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
from ray.tests.conftest import * # noqa
def test_read_map_batches_operator_fusion(ray_start_regular_shared_2_cpus):
ctx = DataContext.get_current()
# Test that Read is fused with MapBatches.
planner = create_planner()
read_op = get_parquet_read_logical_op(parallelism=1)
op = MapBatches(
lambda x: x,
input_dependencies=[read_op],
)
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert op.name == "MapBatches(<lambda>)"
assert physical_op.name == "ReadParquet->MapBatches(<lambda>)"
assert isinstance(physical_op, MapOperator)
assert len(physical_op.input_dependencies) == 1
input = physical_op.input_dependencies[0]
assert isinstance(input, InputDataBuffer)
assert physical_op in input.output_dependencies, input.output_dependencies
assert physical_op._logical_operators == [read_op, op]
def test_read_map_chain_operator_fusion(ray_start_regular_shared_2_cpus):
ctx = DataContext.get_current()
# Test that a chain of different map operators are fused.
planner = create_planner()
read_op = get_parquet_read_logical_op(parallelism=1)
map1 = MapRows(lambda x: x, input_dependencies=[read_op])
map2 = MapBatches(lambda x: x, input_dependencies=[map1])
map3 = FlatMap(lambda x: x, input_dependencies=[map2])
map4 = Filter(fn=lambda x: x, input_dependencies=[map3])
logical_plan = LogicalPlan(map4, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert map4.name == "Filter(<lambda>)"
assert (
physical_op.name == "ReadParquet->Map(<lambda>)->MapBatches(<lambda>)"
"->FlatMap(<lambda>)->Filter(<lambda>)"
)
assert isinstance(physical_op, MapOperator)
assert len(physical_op.input_dependencies) == 1
assert isinstance(physical_op.input_dependencies[0], InputDataBuffer)
assert physical_op._logical_operators == [read_op, map1, map2, map3, map4]
def test_read_map_batches_operator_fusion_compatible_remote_args(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test that map operators are stilled fused when remote args are compatible.
compatiple_remote_args_pairs = [
# Empty remote args are compatible.
({}, {}),
# Test `num_cpus` and `num_gpus`.
({"num_cpus": 2}, {"num_cpus": 2}),
({"num_gpus": 2}, {"num_gpus": 2}),
# `num_cpus` defaults to 1, `num_gpus` defaults to 0.
# The following 2 should be compatible.
({"num_cpus": 1}, {}),
({}, {"num_gpus": 0}),
# Test specifying custom resources.
({"resources": {"custom": 1}}, {"resources": {"custom": 1}}),
({"resources": {"custom": 0}}, {"resources": {}}),
# If the downstream op doesn't have `scheduling_strategy`, it will
# inherit from the upstream op.
({"scheduling_strategy": "SPREAD"}, {}),
]
for up_remote_args, down_remote_args in compatiple_remote_args_pairs:
planner = create_planner()
read_op = get_parquet_read_logical_op(
ray_remote_args={"resources": {"non-existent": 1}},
parallelism=1,
)
op = MapBatches(
lambda x: x, input_dependencies=[read_op], ray_remote_args=up_remote_args
)
op = MapBatches(
lambda x: x, input_dependencies=[op], ray_remote_args=down_remote_args
)
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
optimized_physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = optimized_physical_plan.dag
assert op.name == "MapBatches(<lambda>)", (up_remote_args, down_remote_args)
assert physical_op.name == "MapBatches(<lambda>)->MapBatches(<lambda>)", (
up_remote_args,
down_remote_args,
)
assert isinstance(physical_op, MapOperator), (up_remote_args, down_remote_args)
assert len(physical_op.input_dependencies) == 1, (
up_remote_args,
down_remote_args,
)
assert physical_op.input_dependencies[0].name == "ReadParquet", (
up_remote_args,
down_remote_args,
)
def test_read_map_batches_operator_fusion_incompatible_remote_args(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test that map operators won't get fused if the remote args are incompatible.
incompatible_remote_args_pairs = [
# Use different resources.
({"num_cpus": 2}, {"num_gpus": 2}),
# Same resource, but different values.
({"num_cpus": 3}, {"num_cpus": 2}),
# Incompatible custom resources.
({"resources": {"custom": 2}}, {"resources": {"custom": 1}}),
({"resources": {"custom1": 1}}, {"resources": {"custom2": 1}}),
# Different scheduling strategies.
({"scheduling_strategy": "SPREAD"}, {"scheduling_strategy": "PACK"}),
# Label selectors targeting different ray.io/node-id.
(
{"label_selector": {ray._raylet.RAY_NODE_ID_KEY: "node_A"}},
{"label_selector": {ray._raylet.RAY_NODE_ID_KEY: "node_B"}},
),
]
for up_remote_args, down_remote_args in incompatible_remote_args_pairs:
planner = create_planner()
read_op = get_parquet_read_logical_op(
ray_remote_args={"resources": {"non-existent": 1}}
)
op = MapBatches(
lambda x: x, input_dependencies=[read_op], ray_remote_args=up_remote_args
)
op = MapBatches(
lambda x: x, input_dependencies=[op], ray_remote_args=down_remote_args
)
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert op.name == "MapBatches(<lambda>)", (up_remote_args, down_remote_args)
assert physical_op.name == "MapBatches(<lambda>)", (
up_remote_args,
down_remote_args,
)
assert isinstance(physical_op, MapOperator), (up_remote_args, down_remote_args)
assert len(physical_op.input_dependencies) == 1, (
up_remote_args,
down_remote_args,
)
assert physical_op.input_dependencies[0].name == "MapBatches(<lambda>)", (
up_remote_args,
down_remote_args,
)
def test_read_map_batches_operator_fusion_compute_tasks_to_actors(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test that a task-based map operator is fused into an actor-based map operator when
# the former comes before the latter.
planner = create_planner()
read_op = get_parquet_read_logical_op(parallelism=1)
op = MapBatches(lambda x: x, input_dependencies=[read_op])
op = MapBatches(
lambda x: x, input_dependencies=[op], compute=ray.data.ActorPoolStrategy()
)
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert op.name == "MapBatches(<lambda>)"
assert physical_op.name == "ReadParquet->MapBatches(<lambda>)->MapBatches(<lambda>)"
assert isinstance(physical_op, MapOperator)
assert len(physical_op.input_dependencies) == 1
assert isinstance(physical_op.input_dependencies[0], InputDataBuffer)
def test_read_map_batches_operator_fusion_compute_read_to_actors(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test that reads fuse into an actor-based map operator.
planner = create_planner()
read_op = get_parquet_read_logical_op(parallelism=1)
op = MapBatches(
lambda x: x, input_dependencies=[read_op], compute=ray.data.ActorPoolStrategy()
)
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert op.name == "MapBatches(<lambda>)"
assert physical_op.name == "ReadParquet->MapBatches(<lambda>)"
assert isinstance(physical_op, MapOperator)
assert len(physical_op.input_dependencies) == 1
assert isinstance(physical_op.input_dependencies[0], InputDataBuffer)
def test_read_map_batches_operator_fusion_incompatible_compute(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test that map operators are not fused when compute strategies are incompatible.
planner = create_planner()
read_op = get_parquet_read_logical_op(parallelism=1)
op = MapBatches(
lambda x: x, input_dependencies=[read_op], compute=ray.data.ActorPoolStrategy()
)
op = MapBatches(lambda x: x, input_dependencies=[op])
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
physical_op = physical_plan.dag
assert op.name == "MapBatches(<lambda>)"
assert physical_op.name == "MapBatches(<lambda>)"
assert isinstance(physical_op, MapOperator)
assert len(physical_op.input_dependencies) == 1
upstream_physical_op = physical_op.input_dependencies[0]
assert isinstance(upstream_physical_op, MapOperator)
# Reads should fuse into actor compute.
assert upstream_physical_op.name == "ReadParquet->MapBatches(<lambda>)"
def test_read_with_map_batches_fused_successfully(
ray_start_regular_shared_2_cpus, temp_dir
):
"""Since MapBatches does NOT specify `batch_size`, successfully fused with
ReadParquet"""
# Test that fusion of map operators merges their block sizes in the expected way
# (taking the max).
n = 10
ds = ray.data.range(n)
mapped_ds = ds.map_batches(lambda x: x).map_batches(lambda x: x)
physical_plan, _ = get_execution_plan(mapped_ds._logical_plan)
physical_op = physical_plan.dag
assert isinstance(physical_op, MapOperator)
actual_plan_str = physical_op.dag_str
# All Map ops are fused with Read
assert (
"InputDataBuffer[Input] -> "
"TaskPoolMapOperator[ReadRange->MapBatches(<lambda>)->MapBatches(<lambda>)]"
== actual_plan_str
)
# # Target min-rows requirement is not set
strategy = physical_op._block_ref_bundler._strategy
assert isinstance(strategy, EstimateSize)
assert strategy._min_rows_per_bundle is None
@pytest.mark.parametrize(
"input_op,fused",
[
(
# No fusion (could drastically expand dataset)
Read(
datasource=MagicMock(name="Parquet"),
datasource_or_legacy_reader=MagicMock(
get_read_tasks=lambda _: [MagicMock()]
),
parallelism=1,
),
False,
),
(
# No fusion (could drastically reduce dataset)
Filter(fn=lambda x: False, input_dependencies=[InputData([])]),
False,
),
(
# No fusion (could drastically expand/reduce dataset)
FlatMap(lambda x: x, input_dependencies=[InputData([])]),
False,
),
(
# Fusion
MapBatches(lambda x: x, input_dependencies=[InputData([])]),
True,
),
(
# Fusion
MapRows(lambda x: x, input_dependencies=[InputData([])]),
True,
),
(
# Fusion
Project(exprs=[star()], input_dependencies=[InputData([])]),
True,
),
],
)
def test_map_batches_batch_size_fusion(
ray_start_regular_shared_2_cpus, input_op, fused
):
"""Since MapBatches specifies `batch_size` there's no fusion with ReadParquet"""
context = DataContext.get_current()
# Test that fusion of map operators merges their block sizes in the expected way
# (taking the max).
ds = Dataset(
LogicalPlan(input_op, context),
context,
DatasetStats(metadata={}, parent=None),
)
mapped_ds = ds.map_batches(lambda x: x, batch_size=2).map_batches(
lambda x: x, batch_size=5
)
physical_plan, _ = get_execution_plan(mapped_ds._logical_plan)
physical_op = physical_plan.dag
assert isinstance(physical_op, MapOperator)
actual_plan_str = physical_op.dag_str
if fused:
assert (
f"InputDataBuffer[Input] -> TaskPoolMapOperator[{input_op.name}->"
f"MapBatches(<lambda>)->MapBatches(<lambda>)]" == actual_plan_str
)
else:
assert (
f"InputDataBuffer[Input] -> TaskPoolMapOperator[{input_op.name}] -> "
"TaskPoolMapOperator[MapBatches(<lambda>)->MapBatches(<lambda>)]"
== actual_plan_str
)
# Target min-rows requirement is set to max of upstream and downstream
strategy = physical_op._block_ref_bundler._strategy
assert isinstance(strategy, EstimateSize)
assert strategy._min_rows_per_bundle == 5
assert len(physical_op.input_dependencies) == 1
@pytest.mark.parametrize("upstream_batch_size", [None, 1, 2])
@pytest.mark.parametrize("downstream_batch_size", [None, 1, 2])
def test_map_batches_with_batch_size_specified_fusion(
ray_start_regular_shared_2_cpus,
temp_dir,
upstream_batch_size,
downstream_batch_size,
):
# Test that fusion of map operators merges their block sizes in the expected way
# (taking the max).
n = 10
ds = ray.data.range(n)
mapped_ds = ds.map_batches(
lambda x: x,
batch_size=upstream_batch_size,
).map_batches(
lambda x: x,
batch_size=downstream_batch_size,
)
physical_plan, _ = get_execution_plan(mapped_ds._logical_plan)
root_op = physical_plan.dag
assert isinstance(root_op, MapOperator)
actual_plan_str = root_op.dag_str
if upstream_batch_size is None and downstream_batch_size is None:
expected_min_rows_per_bundle = None
expected_plan_str = (
"InputDataBuffer[Input] -> "
"TaskPoolMapOperator[ReadRange->MapBatches(<lambda>)->MapBatches(<lambda>)]"
)
else:
expected_min_rows_per_bundle = max(
upstream_batch_size or 0, downstream_batch_size or 0
)
expected_plan_str = (
"InputDataBuffer[Input] -> TaskPoolMapOperator[ReadRange] -> "
"TaskPoolMapOperator[MapBatches(<lambda>)->MapBatches(<lambda>)]"
)
assert expected_plan_str == actual_plan_str
# Target min-rows requirement is set to max of upstream and downstream
strategy = root_op._block_ref_bundler._strategy
assert isinstance(strategy, EstimateSize)
assert expected_min_rows_per_bundle == strategy._min_rows_per_bundle
def test_read_map_batches_operator_fusion_with_randomize_blocks_operator(
ray_start_regular_shared_2_cpus,
):
# Note: We currently do not fuse MapBatches->RandomizeBlocks.
# This test is to ensure that we don't accidentally fuse them.
def fn(batch):
return {"id": [x + 1 for x in batch["id"]]}
n = 10
ds = ray.data.range(n)
ds = ds.randomize_block_order()
ds = ds.map_batches(fn, batch_size=None)
assert set(extract_values("id", ds.take_all())) == set(range(1, n + 1))
stats = ds.stats()
# Ensure RandomizeBlockOrder and MapBatches are not fused.
assert "RandomizeBlockOrder->MapBatches(fn)" not in stats
assert "ReadRange" in stats
assert "RandomizeBlockOrder" in stats
assert "MapBatches(fn)" in stats
# Regression tests ensuring RandomizeBlockOrder is never bypassed in the future
assert "ReadRange->MapBatches(fn)->RandomizeBlockOrder" not in stats
assert "ReadRange->MapBatches(fn)" not in stats
# Ensure all three operators are also present in usage record
_check_usage_record(["ReadRange", "MapBatches", "RandomizeBlocks"])
def test_read_map_batches_operator_fusion_with_random_shuffle_operator(
ray_start_regular_shared_2_cpus, configure_shuffle_method
):
# Note: we currently only support fusing MapOperator->AllToAllOperator.
def fn(batch):
return {"id": [x + 1 for x in batch["id"]]}
n = 10
ds = ray.data.range(n)
ds = ds.map_batches(fn, batch_size=None)
ds = ds.random_shuffle()
assert set(extract_values("id", ds.take_all())) == set(range(1, n + 1))
assert "ReadRange->MapBatches(fn)->RandomShuffle" in ds.stats()
_check_usage_record(["ReadRange", "MapBatches", "RandomShuffle"])
ds = ray.data.range(n)
ds = ds.random_shuffle()
ds = ds.map_batches(fn, batch_size=None)
assert set(extract_values("id", ds.take_all())) == set(range(1, n + 1))
# TODO(Scott): Update below assertion after supporting fusion in
# the other direction (AllToAllOperator->MapOperator)
assert "ReadRange->RandomShuffle->MapBatches(fn)" not in ds.stats()
assert all(op in ds.stats() for op in ("ReadRange", "RandomShuffle", "MapBatches"))
_check_usage_record(["ReadRange", "RandomShuffle", "MapBatches"])
# Test fusing multiple `map_batches` with multiple `random_shuffle` operations.
ds = ray.data.range(n)
for _ in range(5):
ds = ds.map_batches(fn, batch_size=None)
ds = ds.random_shuffle()
assert set(extract_values("id", ds.take_all())) == set(range(5, n + 5))
assert f"ReadRange->{'MapBatches(fn)->' * 5}RandomShuffle" in ds.stats()
# For interweaved map_batches and random_shuffle operations, we expect to fuse the
# two pairs of MapBatches->RandomShuffle, but not the resulting
# RandomShuffle operators.
ds = ray.data.range(n)
ds = ds.map_batches(fn, batch_size=None)
ds = ds.random_shuffle()
ds = ds.map_batches(fn, batch_size=None)
ds = ds.random_shuffle()
assert set(extract_values("id", ds.take_all())) == set(range(2, n + 2))
assert "Operator 1 ReadRange->MapBatches(fn)->RandomShuffle" in ds.stats()
assert "Operator 2 MapBatches(fn)->RandomShuffle" in ds.stats()
_check_usage_record(["ReadRange", "RandomShuffle", "MapBatches"])
# Check the case where the upstream map function returns multiple blocks.
ctx = ray.data.DataContext.get_current()
old_target_max_block_size = ctx.target_max_block_size
ctx.target_max_block_size = 100
def fn(_):
return {"data": np.zeros((100, 100))}
ds = ray.data.range(10)
ds = ds.repartition(2).map(fn).random_shuffle().materialize()
assert "Operator 1 ReadRange" in ds.stats()
assert "Operator 2 Repartition" in ds.stats()
assert "Operator 3 Map(fn)->RandomShuffle" in ds.stats()
_check_usage_record(["ReadRange", "RandomShuffle", "MapRows"])
ctx.target_max_block_size = old_target_max_block_size
@pytest.mark.parametrize("shuffle", (True, False))
def test_read_map_batches_operator_fusion_with_repartition_operator(
ray_start_regular_shared_2_cpus, shuffle, configure_shuffle_method
):
def fn(batch):
return {"id": [x + 1 for x in batch["id"]]}
n = 10
ds = ray.data.range(n)
ds = ds.map_batches(fn, batch_size=None)
ds = ds.repartition(2, shuffle=shuffle)
assert set(extract_values("id", ds.take_all())) == set(range(1, n + 1))
# Operator fusion is only supported for shuffle repartition.
if shuffle:
assert "ReadRange->MapBatches(fn)->Repartition" in ds.stats()
else:
assert "ReadRange->MapBatches(fn)->Repartition" not in ds.stats()
assert "ReadRange->MapBatches(fn)" in ds.stats()
assert "Repartition" in ds.stats()
_check_usage_record(["ReadRange", "MapBatches", "Repartition"])
def test_fuse_map_into_shuffle_reduce(
ray_start_regular_shared_2_cpus, restore_data_context
):
DataContext.get_current().shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
ds = ray.data.range(100).repartition(4, keys=["id"]).map_batches(lambda b: b)
dag = get_execution_plan(ds._logical_plan)[0].dag
assert dag.name == (
"HashShuffleReduce(keys=('id',), partitions=4)->MapBatches(<lambda>)"
)
assert dag._fused_output_map_transformer is not None
assert sorted(extract_values("id", ds.take_all())) == list(range(100))
def test_map_not_fused_into_shuffle_reduce_with_downstream_limit(
ray_start_regular_shared_2_cpus, restore_data_context
):
DataContext.get_current().shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
ds = (
ray.data.range(100)
.repartition(4, keys=["id"])
.map_batches(lambda b: b)
.limit(10)
)
dag = get_execution_plan(ds._logical_plan)[0].dag
assert dag.name == "limit=10"
map_op = dag.input_dependencies[0]
assert map_op.name == "MapBatches(<lambda>)"
reduce_op = map_op.input_dependencies[0]
assert reduce_op.name == "HashShuffleReduce(keys=('id',), partitions=4)"
assert reduce_op._fused_output_map_transformer is None
assert len(ds.take_all()) == 10
def test_concurrency_capped_map_not_fused_into_shuffle_reduce(
ray_start_regular_shared_2_cpus, restore_data_context
):
"""A map with a ``concurrency=`` cap is NOT fused into the reduce. The
reduce runs one task per partition with no concurrency cap, so fusing would
silently ignore the user's limit; keeping the map separate honors it."""
DataContext.get_current().shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
ds = (
ray.data.range(100)
.repartition(4, keys=["id"])
.map_batches(lambda b: b, concurrency=2)
)
dag = get_execution_plan(ds._logical_plan)[0].dag
assert dag.name == "MapBatches(<lambda>)"
reduce_op = dag.input_dependencies[0]
assert reduce_op.name == "HashShuffleReduce(keys=('id',), partitions=4)"
assert reduce_op._fused_output_map_transformer is None
def test_non_file_datasink_write_not_fused_into_shuffle_reduce(
ray_start_regular_shared_2_cpus, restore_data_context
):
from ray.data.datasource.datasink import Datasink
DataContext.get_current().shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
class _NoopDatasink(Datasink):
def write(self, blocks, ctx):
for _ in blocks:
pass
return None
repartitioned = ray.data.range(100).repartition(4, keys=["id"])
write_op = Write(
_NoopDatasink(),
input_dependencies=[repartitioned._logical_plan.dag],
)
dag = get_execution_plan(LogicalPlan(write_op, DataContext.get_current()))[0].dag
# The write stays a separate root op feeding off an un-fused reduce.
assert dag.name == "Write"
reduce_op = dag.input_dependencies[0]
assert reduce_op.name == "HashShuffleReduce(keys=('id',), partitions=4)"
assert reduce_op._fused_output_map_transformer is None
def test_read_map_batches_operator_fusion_with_sort_operator(
ray_start_regular_shared_2_cpus,
):
# Note: We currently do not fuse MapBatches->Sort.
# This test is to ensure that we don't accidentally fuse them, until
# we implement it later.
def fn(batch):
return {"id": [x + 1 for x in batch["id"]]}
n = 10
ds = ray.data.range(n)
ds = ds.map_batches(fn, batch_size=None)
ds = ds.sort("id")
assert extract_values("id", ds.take_all()) == list(range(1, n + 1))
# TODO(Scott): update the below assertions after we support fusion.
assert "ReadRange->MapBatches->Sort" not in ds.stats()
assert "ReadRange->MapBatches" in ds.stats()
assert "Sort" in ds.stats()
_check_usage_record(["ReadRange", "MapBatches", "Sort"])
def test_read_map_batches_operator_fusion_with_aggregate_operator(
ray_start_regular_shared_2_cpus, configure_shuffle_method
):
from ray.data.aggregate import AggregateFn
# Note: We currently do not fuse MapBatches->Aggregate.
# This test is to ensure that we don't accidentally fuse them, until
# we implement it later.
def fn(batch):
return {"id": [x % 2 for x in batch["id"]]}
n = 100
grouped_ds = ray.data.range(n).map_batches(fn, batch_size=None).groupby("id")
agg_ds = grouped_ds.aggregate(
AggregateFn(
init=lambda k: [0, 0],
accumulate_row=lambda a, r: [a[0] + r["id"], a[1] + 1],
merge=lambda a1, a2: [a1[0] + a2[0], a1[1] + a2[1]],
finalize=lambda a: a[0] / a[1],
name="foo",
),
)
agg_ds.take_all() == [{"id": 0, "foo": 0.0}, {"id": 1, "foo": 1.0}]
# TODO(Scott): update the below assertions after we support fusion.
assert "ReadRange->MapBatches->Aggregate" not in agg_ds.stats()
assert "ReadRange->MapBatches" in agg_ds.stats()
assert "Aggregate" in agg_ds.stats()
_check_usage_record(["ReadRange", "MapBatches", "Aggregate"])
def test_read_map_chain_operator_fusion_e2e(
ray_start_regular_shared_2_cpus,
):
ds = ray.data.range(10, override_num_blocks=2)
ds = ds.filter(fn=lambda x: x["id"] % 2 == 0)
ds = ds.map(column_udf("id", lambda x: x + 1))
ds = ds.map_batches(
lambda batch: {"id": [2 * x for x in batch["id"]]}, batch_size=None
)
ds = ds.flat_map(lambda x: [{"id": -x["id"]}, {"id": x["id"]}])
assert extract_values("id", ds.take_all()) == [
-2,
2,
-6,
6,
-10,
10,
-14,
14,
-18,
18,
]
name = (
"ReadRange->Filter(<lambda>)->Map(<lambda>)"
"->MapBatches(<lambda>)->FlatMap(<lambda>):"
)
assert name in ds.stats()
_check_usage_record(["ReadRange", "Filter", "MapRows", "MapBatches", "FlatMap"])
def test_write_fusion(ray_start_regular_shared_2_cpus, tmp_path):
ds = ray.data.range(10, override_num_blocks=2)
ds.write_csv(tmp_path)
assert "ReadRange->Write" in ds._write_ds.stats()
_check_usage_record(["ReadRange", "WriteCSV"])
@pytest.mark.parametrize(
"up_use_actor, up_concurrency, down_use_actor, down_concurrency, should_fuse",
[
# === Task->Task cases ===
# Same concurrency set. Should fuse.
(False, 1, False, 1, True),
# Different concurrency set. Should not fuse.
(False, 1, False, 2, False),
# If one op has concurrency set, and the other doesn't, should not fuse.
(False, None, False, 1, False),
(False, 1, False, None, False),
# === Task->Actor cases ===
# When Task's concurrency is not set, should fuse.
(False, None, True, 2, True),
(False, None, True, (1, 2), True),
# When max size matches, should fuse.
(False, 2, True, 2, True),
(False, 2, True, (1, 2), True),
# When max size doesn't match, should not fuse.
(False, 1, True, 2, False),
(False, 1, True, (1, 2), False),
# === Actor->Task cases ===
# Should not fuse whatever concurrency is set.
(True, 2, False, 2, False),
# === Actor->Actor cases ===
# Should not fuse whatever concurrency is set.
(True, 2, True, 2, False),
],
)
def test_map_fusion_with_concurrency_arg(
ray_start_regular_shared_2_cpus,
up_use_actor,
up_concurrency,
down_use_actor,
down_concurrency,
should_fuse,
):
"""Test map operator fusion with different concurrency settings."""
class Map:
def __call__(self, row):
return row
def map(row):
return row
ds = ray.data.range(10, override_num_blocks=2)
if not up_use_actor:
ds = ds.map(map, num_cpus=0, concurrency=up_concurrency)
up_name = "Map(map)"
else:
ds = ds.map(Map, num_cpus=0, concurrency=up_concurrency)
up_name = "Map(Map)"
if not down_use_actor:
ds = ds.map(map, num_cpus=0, concurrency=down_concurrency)
down_name = "Map(map)"
else:
ds = ds.map(Map, num_cpus=0, concurrency=down_concurrency)
down_name = "Map(Map)"
actual_data = ds.to_pandas()
expected_data = pd.DataFrame({"id": list(range(10))})
assert rows_same(actual_data, expected_data)
name = f"{up_name}->{down_name}"
stats = ds.stats()
if should_fuse:
assert name in stats, stats
else:
assert name not in stats, stats
def check_transform_fns(op, expected_types):
assert isinstance(op, MapOperator)
transform_fns = op.get_map_transformer().get_transform_fns()
assert len(transform_fns) == len(expected_types), transform_fns
for i, transform_fn in enumerate(transform_fns):
assert isinstance(transform_fn, expected_types[i]), transform_fn
@pytest.mark.skip("Needs zero-copy optimization for read->map_batches.")
def test_zero_copy_fusion_eliminate_build_output_blocks(
ray_start_regular_shared_2_cpus,
):
ctx = DataContext.get_current()
# Test the EliminateBuildOutputBlocks optimization rule.
planner = create_planner()
read_op = get_parquet_read_logical_op()
op = MapBatches(lambda x: x, input_dependencies=[read_op])
logical_plan = LogicalPlan(op, ctx)
physical_plan, _ = planner.plan(logical_plan)
# Before optimization, there should be a map op and and read op.
# And they should have the following transform_fns.
map_op = physical_plan.dag
check_transform_fns(
map_op,
[
BatchMapTransformFn,
],
)
read_op = map_op.input_dependencies[0]
check_transform_fns(
read_op,
[
BlockMapTransformFn,
],
)
physical_plan = PhysicalOptimizer().optimize(physical_plan)
fused_op = physical_plan.dag
# After optimization, read and map ops should be fused as one op.
# And the BuidlOutputBlocksMapTransformFn in the middle should be dropped.
check_transform_fns(
fused_op,
[
BlockMapTransformFn,
BatchMapTransformFn,
],
)
@pytest.mark.parametrize(
"order,target_num_rows,batch_size,should_fuse",
[
# map_batches -> streaming_repartition: fuse when batch_size is a multiple of target_num_rows
("map_then_sr", 20, 20, True),
("map_then_sr", 20, 10, False),
("map_then_sr", 20, 40, True),
("map_then_sr", 20, None, False),
# streaming_repartition -> map_batches: not fused
("sr_then_map", 20, 20, False),
],
)
def test_streaming_repartition_map_batches_fusion_order_and_params(
ray_start_regular_shared_2_cpus,
order,
target_num_rows,
batch_size,
should_fuse,
):
"""Test fusion of streaming_repartition and map_batches with different orders
and different target_num_rows/batch_size values."""
n = 100
ds = ray.data.range(n, override_num_blocks=2)
if order == "map_then_sr":
ds = ds.map_batches(lambda x: x, batch_size=batch_size)
ds = ds.repartition(target_num_rows_per_block=target_num_rows, strict=True)
expected_fused_name = f"MapBatches(<lambda>)->StreamingRepartition[num_rows_per_block={target_num_rows},strict=True]"
else: # sr_then_map
ds = ds.repartition(target_num_rows_per_block=target_num_rows, strict=True)
ds = ds.map_batches(lambda x: x, batch_size=batch_size)
expected_fused_name = f"StreamingRepartition[num_rows_per_block={target_num_rows},strict=True]->MapBatches(<lambda>)"
assert len(ds.take_all()) == n
stats = ds.stats()
if should_fuse:
assert (
expected_fused_name in stats
), f"Expected '{expected_fused_name}' in stats: {stats}"
else:
assert (
expected_fused_name not in stats
), f"Did not expect '{expected_fused_name}' in stats: {stats}"
def test_streaming_repartition_no_further_fuse(
ray_start_regular_shared_2_cpus,
):
"""Test that streaming_repartition (strict mode) blocks fusion with downstream operators.
Case 1: map_batches -> map_batches -> streaming_repartition(strict=True) -> map_batches -> map_batches
Result: (map -> map -> s_r) -> (map -> map)
SR can fuse with upstream maps but not with downstream maps to preserve parallelism.
"""
n = 100
target_rows = 20
# Case 1: map_batches -> map_batches -> streaming_repartition(strict=True) -> map_batches -> map_batches
# Result: (map -> map -> s_r) -> (map -> map)
ds1 = ray.data.range(n, override_num_blocks=2)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
ds1 = ds1.repartition(target_num_rows_per_block=target_rows, strict=True)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
assert len(ds1.take_all()) == n
stats1 = ds1.stats()
assert (
f"MapBatches(<lambda>)->MapBatches(<lambda>)->StreamingRepartition[num_rows_per_block={target_rows},strict=True]"
in stats1
), stats1
assert "MapBatches(<lambda>)->MapBatches(<lambda>)" in stats1
def test_filter_operator_no_upstream_fusion(ray_start_regular_shared_2_cpus, capsys):
"""Test that fused filter operators doesn't fuse further with upstream maps
that specify batch_size (since it filter can change the # of rows.)
Case 1: map_batches -> filter -> map_batchess
Result: (map -> filter) -> map
The fused (map -> filter) doesn't fuse with upstream maps.
"""
n = 100
target_rows = 20
ds1 = ray.data.range(n, override_num_blocks=2)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
ds1 = ds1.filter(lambda x: True)
ds1 = ds1.map_batches(lambda x: x, batch_size=target_rows)
ds1.explain()
captured = capsys.readouterr().out.strip()
assert "TaskPoolMapOperator[MapBatches(<lambda>)]" in captured
assert "TaskPoolMapOperator[MapBatches(<lambda>)->Filter(<lambda>)]" in captured
def test_streaming_repartition_multiple_fusion_non_strict(
ray_start_regular_shared_2_cpus,
):
"""Test that non-strict mode allows multiple operators to fuse with StreamingRepartition.
Case 1: Map > Map > SR (non-strict)
Case 2: Map > SR (non-strict) > SR (non-strict)
"""
n = 100
target_rows = 20
# Case 1: Map > Map > SR (non-strict)
ds1 = ray.data.range(n, override_num_blocks=2)
ds1 = ds1.map_batches(lambda x: x, batch_size=None)
ds1 = ds1.map_batches(lambda x: x, batch_size=None)
ds1 = ds1.repartition(target_num_rows_per_block=target_rows, strict=False)
assert len(ds1.take_all()) == n
stats1 = ds1.stats()
# Verify all three operators are fused together
assert (
f"MapBatches(<lambda>)->MapBatches(<lambda>)->StreamingRepartition[num_rows_per_block={target_rows},strict=False]"
in stats1
), f"Expected full fusion in stats: {stats1}"
# Case 2: Map > SR (non-strict) > SR (non-strict)
# Note: Two consecutive StreamingRepartition operators are merged into one by
# CombineShuffles._combine() during logical optimization (before physical fusion).
# This test verifies that Map > SR fusion still works after the SR merging.
ds2 = ray.data.range(n, override_num_blocks=2)
ds2 = ds2.map_batches(lambda x: x, batch_size=None)
ds2 = ds2.repartition(target_num_rows_per_block=target_rows, strict=False)
ds2 = ds2.repartition(target_num_rows_per_block=target_rows, strict=False)
assert len(ds2.take_all()) == n
stats2 = ds2.stats()
# Verify Map > SR fusion (the two SRs were already merged into one)
assert (
f"MapBatches(<lambda>)->StreamingRepartition[num_rows_per_block={target_rows},strict=False]"
in stats2
), f"Expected Map->SR fusion in stats: {stats2}"
def test_combine_repartition_aggregate(
ray_start_regular_shared_2_cpus, configure_shuffle_method, capsys
):
ds = ray.data.range(100)
# Apply repartition with shuffle
ds = ds.repartition(5, shuffle=True)
# Apply groupby aggregate (creates Aggregate operator)
ds = ds.groupby("id").count()
ds.explain()
captured = capsys.readouterr().out
# Verify the first shuffle (Repartition) was dropped and Aggregate connects directly to Read
expected_optimized_plan = (
"-------- Logical Plan (Optimized) --------\n"
"Aggregate[Aggregate]\n"
"+- Read[ReadRange]"
)
assert expected_optimized_plan in captured
def test_combine_streaming_repartition_to_shuffle_repartition(
ray_start_regular_shared_2_cpus, configure_shuffle_method, capsys
):
ds = ray.data.range(100, override_num_blocks=10)
# Apply StreamingRepartition (local repartition)
ds = ds.repartition(target_num_rows_per_block=20)
# Apply shuffle Repartition (global repartition)
ds = ds.repartition(num_blocks=3, shuffle=True)
ds.explain()
captured = capsys.readouterr().out
# Verify the first shuffle (StreamingRepartition) was dropped and Repartition connects directly to Read
expected_optimized_plan = (
"-------- Logical Plan (Optimized) --------\n"
"Repartition[Repartition]\n"
"+- Read[ReadRange]"
)
assert expected_optimized_plan in captured
def test_combine_sort_sort(ray_start_regular_shared_2_cpus, capsys):
data = [{"a": i, "b": 100 - i} for i in range(50)]
ds = ray.data.from_items(data)
# Apply first sort on column 'a'
ds = ds.sort("a")
# Apply second sort on column 'b'
ds = ds.sort("b")
ds.explain()
captured = capsys.readouterr().out
# Verify the first shuffle (first Sort) was dropped and only the second Sort remains
expected_optimized_plan = (
"-------- Logical Plan (Optimized) --------\n"
"Sort[Sort]\n"
"+- FromItems[FromItems]"
)
assert expected_optimized_plan in captured
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))