chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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__]))
|
||||
Reference in New Issue
Block a user