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

284 lines
9.8 KiB
Python

import collections
import itertools
import random
import pytest
import ray
from ray.data._internal.execution.interfaces import ExecutionOptions
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
from ray.data._internal.execution.operators.output_splitter import OutputSplitter
from ray.data._internal.execution.util import make_ref_bundles
from ray.data.context import DataContext
from ray.data.tests.conftest import noop_counter
from ray.tests.conftest import * # noqa
@pytest.mark.parametrize("equal", [False, True])
@pytest.mark.parametrize("chunk_size", [1, 10])
def test_split_operator(ray_start_regular_shared, equal, chunk_size):
num_input_blocks = 100
num_splits = 3
# Add this many input blocks each time.
# Make sure it is greater than num_splits * 2,
# so we can test the output order of `OutputSplitter.get_next`.
num_add_input_blocks = 10
input_op = InputDataBuffer(
DataContext.get_current(),
make_ref_bundles([[i] * chunk_size for i in range(num_input_blocks)]),
)
op = OutputSplitter(
input_op,
num_splits,
equal=equal,
data_context=DataContext.get_current(),
)
# Feed data and implement streaming exec.
output_splits = [[] for _ in range(num_splits)]
op.start(ExecutionOptions(), noop_counter())
while input_op.has_next():
for _ in range(num_add_input_blocks):
if not input_op.has_next():
break
op.add_input(input_op.get_next(), 0)
while op.has_next():
ref = op.get_next()
assert ref.owns_blocks, ref
for block_ref in ref.block_refs:
assert ref.output_split_idx is not None
output_splits[ref.output_split_idx].extend(
list(ray.get(block_ref)["id"])
)
op.all_inputs_done()
expected_splits = [[] for _ in range(num_splits)]
for i in range(num_splits):
for j in range(i, num_input_blocks, num_splits):
expected_splits[i].extend([j] * chunk_size)
if equal:
min_len = min(len(expected_splits[i]) for i in range(num_splits))
for i in range(num_splits):
expected_splits[i] = expected_splits[i][:min_len]
for i in range(num_splits):
assert output_splits[i] == expected_splits[i], (
output_splits[i],
expected_splits[i],
)
@pytest.mark.parametrize("equal", [False, True])
@pytest.mark.parametrize("random_seed", list(range(10)))
def test_split_operator_random(ray_start_regular_shared, equal, random_seed):
random.seed(random_seed)
inputs = make_ref_bundles([[i] * random.randint(0, 10) for i in range(100)])
num_inputs = sum(x.num_rows() for x in inputs)
input_op = InputDataBuffer(DataContext.get_current(), inputs)
op = OutputSplitter(
input_op, 3, equal=equal, data_context=DataContext.get_current()
)
# Feed data and implement streaming exec.
output_splits = collections.defaultdict(list)
op.start(ExecutionOptions(), noop_counter())
while input_op.has_next():
op.add_input(input_op.get_next(), 0)
op.all_inputs_done()
while op.has_next():
ref = op.get_next()
assert ref.owns_blocks, ref
for block_ref in ref.block_refs:
output_splits[ref.output_split_idx].extend(list(ray.get(block_ref)["id"]))
if equal:
actual = [len(output_splits[i]) for i in range(3)]
expected = [num_inputs // 3] * 3
assert actual == expected
else:
assert sum(len(output_splits[i]) for i in range(3)) == num_inputs, output_splits
def test_split_operator_locality_hints(ray_start_regular_shared):
input_op = InputDataBuffer(
DataContext.get_current(), make_ref_bundles([[i] for i in range(10)])
)
op = OutputSplitter(
input_op,
2,
equal=False,
data_context=DataContext.get_current(),
locality_hints=["node1", "node2"],
)
def get_fake_loc(item):
assert isinstance(item, int), item
if item in [0, 1, 4, 5, 8]:
return "node1"
else:
return "node2"
def get_bundle_loc(bundle):
block = ray.get(bundle.blocks[0].ref)
fval = list(block["id"])[0]
return [get_fake_loc(fval)]
op._get_locations = get_bundle_loc
# Feed data and implement streaming exec.
output_splits = collections.defaultdict(list)
op.start(ExecutionOptions(actor_locality_enabled=True), noop_counter())
while input_op.has_next():
op.add_input(input_op.get_next(), 0)
op.all_inputs_done()
while op.has_next():
ref = op.get_next()
assert ref.owns_blocks, ref
for block_ref in ref.block_refs:
output_splits[ref.output_split_idx].extend(list(ray.get(block_ref)["id"]))
total = 0
for i in range(2):
if i == 0:
node = "node1"
else:
node = "node2"
split = output_splits[i]
for item in split:
assert get_fake_loc(item) == node
total += 1
assert total == 10, total
assert "all objects local" in op.progress_str()
@pytest.mark.parametrize("equal", [False, True])
@pytest.mark.parametrize("random_seed", list(range(10)))
def test_split_operator_with_locality(ray_start_regular_shared, equal, random_seed):
"""Test locality-based dispatching with equal=True and equal=False modes.
This test verifies that the OutputSplitter:
1. Correctly buffers data to ensure equal distribution when equal=True
2. Respects locality hints in both modes
3. Yields blocks incrementally when locality is matched (streaming behavior)
4. The fix ensures that _can_safely_dispatch correctly calculates remaining
buffer requirements.
"""
random.seed(random_seed)
# Create bundles with varying sizes to test buffer management
input_bundles = make_ref_bundles([[i] * random.randint(1, 10) for i in range(100)])
num_inputs = sum(x.num_rows() for x in input_bundles)
input_op = InputDataBuffer(DataContext.get_current(), input_bundles)
op = OutputSplitter(
input_op,
3,
equal=equal,
data_context=DataContext.get_current(),
locality_hints=["node0", "node1", "node2"],
)
# Mock locality function: distribute items across 3 nodes
def _map_row_to_node(first_row_id_val) -> str:
return f"node{first_row_id_val % 3}"
def _get_fake_bundle_loc(bundle):
block = ray.get(bundle.block_refs[0])
first_row_id_val = block["id"][0]
return [_map_row_to_node(first_row_id_val)]
op._get_locations = _get_fake_bundle_loc
# Feed data and implement streaming exec
output_splits = [[] for _ in range(3)]
yielded_incrementally = 0
op.start(ExecutionOptions(actor_locality_enabled=True), noop_counter())
while input_op.has_next():
op.add_input(input_op.get_next(), 0)
# Drain some outputs to simulate streaming consumption
while op.has_next():
yielded_incrementally += 1
ref = op.get_next()
assert ref.owns_blocks, ref
for block_ref in ref.block_refs:
output_splits[ref.output_split_idx].extend(
list(ray.get(block_ref)["id"])
)
op.all_inputs_done()
# Collect remaining outputs
while op.has_next():
ref = op.get_next()
assert ref.owns_blocks, ref
for block_ref in ref.block_refs:
output_splits[ref.output_split_idx].extend(list(ray.get(block_ref)["id"]))
# Verify streaming behavior: outputs should be yielded before all inputs are done
# With locality hints, we should see outputs during input phase
assert yielded_incrementally > 0, (
f"Expected incremental output with locality hints, but got 0 outputs during "
f"{len(input_bundles)} input blocks. This suggests buffering all data instead of streaming."
)
# Verify equal distribution when equal=True
if equal:
actual = [len(output_splits[i]) for i in range(3)]
expected = [num_inputs // 3] * 3
assert (
actual == expected
), f"Expected equal distribution {expected}, got {actual}"
else:
# In non-equal mode, verify all data is output with correct row IDs
all_output_row_ids = set(itertools.chain.from_iterable(output_splits))
# Reconstruct expected row IDs from the input bundles
expected_row_ids = set()
for b in input_bundles:
id_col = ray.get(b.block_refs[0])["id"]
expected_row_ids.update(list(id_col))
assert all_output_row_ids == expected_row_ids
# Verify locality was respected (most items should be on their preferred node)
locality_hits = 0
total = 0
for split_idx in range(3):
actual_node = f"node{split_idx}"
for row_id in output_splits[split_idx]:
total += 1
expected_node = _map_row_to_node(row_id)
assert expected_node in ["node0", "node1", "node2"], expected_node
if expected_node == actual_node:
locality_hits += 1
# Should have excellent locality since bundles are dispatched based on locality hints.
# With perfect locality we'd get 100%, but buffering for equal distribution and
# occasional forced dispatches when buffer is full may cause some misses.
# We expect at least 85% locality hit rate, which validates the feature is working.
locality_ratio = locality_hits / total if total > 0 else 0
# NOTE: 90% is an observed locality ratio that should be fixed for this test
assert locality_ratio >= 0.85, (
f"Locality ratio {locality_ratio:.2f} too low. "
f"Expected >=85% with locality-aware dispatching. "
f"Hits: {locality_hits}/{total}"
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))