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

319 lines
12 KiB
Python

from collections import defaultdict
import pytest
import ray
from ray._raylet import NodeID
from ray.data._internal.planner.exchange.push_based_shuffle_task_scheduler import (
PushBasedShuffleTaskScheduler,
)
def test_push_based_shuffle_schedule():
def _test(num_input_blocks, merge_factor, num_cpus_per_node_map):
num_cpus = sum(v for v in num_cpus_per_node_map.values())
op_cls = PushBasedShuffleTaskScheduler
schedule = op_cls._compute_shuffle_schedule(
num_cpus_per_node_map, num_input_blocks, merge_factor, num_input_blocks
)
# All input blocks will be processed.
assert (
schedule.num_rounds * schedule.num_map_tasks_per_round >= num_input_blocks
)
# Each round of tasks does not over-subscribe CPUs.
assert (
schedule.num_map_tasks_per_round
+ schedule.merge_schedule.num_merge_tasks_per_round
<= max(num_cpus, 2)
)
print(
"map",
schedule.num_map_tasks_per_round,
"merge",
schedule.merge_schedule.num_merge_tasks_per_round,
"num_cpus",
num_cpus,
"merge_factor",
merge_factor,
)
# Merge factor between map : merge tasks is approximately correct.
if schedule.num_map_tasks_per_round > merge_factor:
actual_merge_factor = (
schedule.num_map_tasks_per_round
/ schedule.merge_schedule.num_merge_tasks_per_round
)
next_highest_merge_factor = schedule.num_map_tasks_per_round / (
schedule.merge_schedule.num_merge_tasks_per_round + 1
)
assert actual_merge_factor - 1 <= merge_factor <= actual_merge_factor + 1, (
next_highest_merge_factor,
merge_factor,
actual_merge_factor,
)
else:
assert schedule.merge_schedule.num_merge_tasks_per_round == 1, (
schedule.num_map_tasks_per_round,
merge_factor,
)
# Tasks are evenly distributed.
tasks_per_node = defaultdict(int)
for i in range(schedule.merge_schedule.num_merge_tasks_per_round):
task_options = schedule.get_merge_task_options(i)
node_id = task_options["scheduling_strategy"].node_id
tasks_per_node[node_id] += 1
low = min(tasks_per_node.values())
high = low + 1
assert low <= max(tasks_per_node.values()) <= high
# Reducers are evenly distributed across mergers.
num_reducers_per_merge_idx = [
schedule.merge_schedule.get_num_reducers_per_merge_idx(i)
for i in range(schedule.merge_schedule.num_merge_tasks_per_round)
]
high = max(num_reducers_per_merge_idx)
for num_reducers in num_reducers_per_merge_idx:
assert num_reducers == high or num_reducers == high - 1
for merge_idx in range(schedule.merge_schedule.num_merge_tasks_per_round):
assert isinstance(
schedule.merge_schedule.get_num_reducers_per_merge_idx(merge_idx), int
)
assert schedule.merge_schedule.get_num_reducers_per_merge_idx(merge_idx) > 0
reduce_idxs = list(range(schedule.merge_schedule.output_num_blocks))
actual_num_reducers_per_merge_idx = [
0 for _ in range(schedule.merge_schedule.num_merge_tasks_per_round)
]
for reduce_idx in schedule.merge_schedule.round_robin_reduce_idx_iterator():
reduce_idxs.pop(reduce_idxs.index(reduce_idx))
actual_num_reducers_per_merge_idx[
schedule.merge_schedule.get_merge_idx_for_reducer_idx(reduce_idx)
] += 1
# Check that each reduce task is submitted exactly once.
assert len(reduce_idxs) == 0
# Check that each merge and reduce task are correctly paired.
for i, num_reducers in enumerate(actual_num_reducers_per_merge_idx):
assert (
num_reducers == num_reducers_per_merge_idx[i]
), f"""Merge task [{i}] has {num_reducers} downstream reduce tasks,
expected {num_reducers_per_merge_idx[i]}."""
assert num_reducers > 0
node_id_1 = NodeID.from_random().hex()
node_id_2 = NodeID.from_random().hex()
node_id_3 = NodeID.from_random().hex()
for num_cpus in range(1, 20):
_test(20, 3, {node_id_1: num_cpus})
_test(20, 3, {node_id_1: 100})
_test(100, 3, {node_id_1: 10, node_id_2: 10, node_id_3: 10})
_test(100, 10, {node_id_1: 10, node_id_2: 10, node_id_3: 10})
# Regression test for https://github.com/ray-project/ray/issues/25863.
_test(1000, 2, {NodeID.from_random().hex(): 16 for i in range(20)})
# Regression test for https://github.com/ray-project/ray/issues/37754.
_test(260, 2, {node_id_1: 128})
_test(1, 2, {node_id_1: 128})
# Test float merge_factor.
for cluster_config in [
{node_id_1: 10},
{node_id_1: 10, node_id_2: 10},
]:
_test(100, 1, cluster_config)
_test(100, 1.3, cluster_config)
_test(100, 1.6, cluster_config)
_test(100, 1.75, cluster_config)
_test(100, 2, cluster_config)
_test(1, 1.2, cluster_config)
_test(2, 1.2, cluster_config)
def test_push_based_shuffle_stats(ray_start_cluster):
ctx = ray.data.context.DataContext.get_current()
try:
original = ctx.use_push_based_shuffle
ctx.use_push_based_shuffle = True
cluster = ray_start_cluster
cluster.add_node(
resources={"bar:1": 100},
num_cpus=10,
_system_config={"max_direct_call_object_size": 0},
)
cluster.add_node(resources={"bar:2": 100}, num_cpus=10)
cluster.add_node(resources={"bar:3": 100}, num_cpus=0)
ray.init(cluster.address)
parallelism = 100
ds = ray.data.range(1000, override_num_blocks=parallelism).random_shuffle()
ds = ds.materialize()
assert "RandomShuffleMerge" in ds.stats()
# Check all nodes used.
assert "2 nodes used" in ds.stats()
assert "1 nodes used" not in ds.stats()
# Check all merge tasks are included in stats.
internal_stats = ds._raw_stats()
num_merge_tasks = len(internal_stats.metadata["RandomShuffleMerge"])
# Merge factor is 2 for random_shuffle ops.
merge_factor = 2
assert (
parallelism // (merge_factor + 1)
<= num_merge_tasks
<= parallelism // merge_factor
)
finally:
ctx.use_push_based_shuffle = original
def test_sort_multinode(ray_start_cluster, configure_shuffle_method):
cluster = ray_start_cluster
cluster.add_node(
resources={"bar:1": 100},
num_cpus=10,
_system_config={"max_direct_call_object_size": 0},
)
cluster.add_node(resources={"bar:2": 100}, num_cpus=10)
cluster.add_node(resources={"bar:3": 100}, num_cpus=0)
ray.init(cluster.address)
parallelism = 100
ds = (
ray.data.range(1000, override_num_blocks=parallelism)
.random_shuffle()
.sort("id")
)
for i, row in enumerate(ds.iter_rows()):
assert row["id"] == i
def patch_ray_remote(condition, callback):
original_ray_remote = ray.remote
def ray_remote_override(*args, **kwargs):
def wrapper(fn):
remote_fn = original_ray_remote(*args, **kwargs)(fn)
if condition(fn):
original_remote_options = remote_fn.options
def options(**task_options):
callback(task_options)
original_options = original_remote_options(**task_options)
return original_options
remote_fn.options = options
return remote_fn
return wrapper
ray.remote = ray_remote_override
return original_ray_remote
def patch_ray_get(callback):
original_ray_get = ray.get
def ray_get_override(object_refs, *args, **kwargs):
callback(object_refs)
return original_ray_get(object_refs, *args, **kwargs)
ray.get = ray_get_override
return original_ray_get
@pytest.mark.parametrize("pipeline", [False, True])
def test_push_based_shuffle_reduce_stage_scheduling(ray_start_cluster, pipeline):
ctx = ray.data.context.DataContext.get_current()
try:
original = ctx.use_push_based_shuffle
ctx.use_push_based_shuffle = True
ctx.pipeline_push_based_shuffle_reduce_tasks = pipeline
num_cpus_per_node = 8
num_nodes = 3
num_output_blocks = 100
task_context = {
"reduce_options_submitted": [],
# The total number of CPUs available.
"pipelined_parallelism": num_cpus_per_node * num_nodes,
# The total number of reduce tasks.
"total_parallelism": num_output_blocks,
"num_instances_below_parallelism": 0,
}
def reduce_options_patch(task_options):
task_context["reduce_options_submitted"].append(task_options)
def check_pipelined(refs):
if task_context["reduce_options_submitted"]:
# Check that we have the correct number of tasks in flight.
if pipeline:
# When pipelining, we should limit the number of reduce
# tasks in flight based on how many CPUs are in the
# cluster.
if not (
task_context["pipelined_parallelism"]
<= len(task_context["reduce_options_submitted"])
<= 2 * task_context["pipelined_parallelism"]
):
task_context["num_instances_below_parallelism"] += 1
else:
# When not pipelining, we should submit all reduce tasks at
# once.
assert (
len(task_context["reduce_options_submitted"])
== task_context["total_parallelism"]
)
# Check that tasks are close to evenly spread across the nodes.
nodes = defaultdict(int)
for options in task_context["reduce_options_submitted"]:
nodes[options["scheduling_strategy"].node_id] += 1
assert len(nodes) > 1
assert min(nodes.values()) >= max(nodes.values()) // 2
task_context["reduce_options_submitted"].clear()
ray_remote = patch_ray_remote(
lambda fn: "reduce" in fn.__name__, reduce_options_patch
)
ray_get = patch_ray_get(check_pipelined)
cluster = ray_start_cluster
for _ in range(num_nodes):
cluster.add_node(
num_cpus=num_cpus_per_node,
)
ray.init(cluster.address)
ds = ray.data.range(
1000, override_num_blocks=num_output_blocks
).random_shuffle()
# Only the last round should have fewer tasks in flight.
assert task_context["num_instances_below_parallelism"] <= 1
task_context["num_instances_below_parallelism"] = 0
ds = ds.sort("id")
# Only the last round should have fewer tasks in flight.
assert task_context["num_instances_below_parallelism"] <= 1
task_context["num_instances_below_parallelism"] = 0
for i, row in enumerate(ds.iter_rows()):
assert row["id"] == i
finally:
ctx.use_push_based_shuffle = original
ray.remote = ray_remote
ray.get = ray_get
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-sv", __file__]))