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__]))