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

1040 lines
37 KiB
Python

import itertools
import math
import random
import threading
import time
from typing import Any, List, Tuple
from unittest.mock import patch
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data._internal.equalize import _equalize
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle
from ray.data._internal.execution.interfaces.ref_bundle import (
_ref_bundles_iterator_to_block_refs_list,
)
from ray.data._internal.logical.interfaces import LogicalPlan
from ray.data._internal.logical.operators import InputData
from ray.data._internal.split import (
_drop_empty_block_split,
_generate_global_split_results,
_generate_per_block_split_indices,
_generate_valid_indices,
_split_at_indices,
_split_single_block,
)
from ray.data._internal.stats import DatasetStats
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.data.context import DataContext
from ray.data.dataset import Dataset
from ray.data.tests.conftest import * # noqa
from ray.data.tests.util import extract_values
from ray.tests.conftest import * # noqa
from ray.types import ObjectRef
@ray.remote
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
return self.value
def test_equal_split(shutdown_only):
ray.init(num_cpus=2)
def range2x(n):
return ray.data.range(2 * n)
def counts(shards):
@ray.remote(num_cpus=0)
def count(s):
return s.count()
return ray.get([count.remote(s) for s in shards])
r1 = counts(range2x(10).split(3, equal=True))
assert all(c == 6 for c in r1), r1
# The following test is failing and may be a regression.
# Splits appear to be based on existing block boundaries ([10, 5, 5], [8, 8, 4]).
# r2 = counts(range2x(10).split(3, equal=False))
# assert all(c >= 6 for c in r2), r2
# assert not all(c == 6 for c in r2), r2
@pytest.mark.parametrize(
"block_sizes,num_splits",
[
([3, 6, 3], 3), # Test baseline.
([3, 3, 3], 3), # Already balanced.
([3, 6, 4], 3), # Row truncation.
([3, 6, 2, 3], 3), # Row truncation, smaller number of blocks.
([5, 6, 2, 5], 5), # Row truncation, larger number of blocks.
([1, 1, 1, 1, 6], 5), # All smaller but one.
([4, 4, 4, 4, 1], 5), # All larger but one.
([2], 2), # Single block.
([2, 5], 1), # Single split.
],
)
def test_equal_split_balanced(ray_start_regular_shared_2_cpus, block_sizes, num_splits):
_test_equal_split_balanced(block_sizes, num_splits)
def _test_equal_split_balanced(block_sizes, num_splits):
ctx = DataContext.get_current()
blocks = []
metadata = []
ref_bundles = []
total_rows = 0
for block_size in block_sizes:
block = pd.DataFrame({"id": list(range(total_rows, total_rows + block_size))})
blocks.append(ray.put(block))
metadata.append(BlockAccessor.for_block(block).get_metadata())
schema = BlockAccessor.for_block(block).schema()
blk = BlockEntry(blocks[-1], metadata[-1])
ref_bundles.append(RefBundle((blk,), owns_blocks=True, schema=schema))
total_rows += block_size
logical_plan = LogicalPlan(InputData(input_data=ref_bundles), ctx)
stats = DatasetStats(metadata={"TODO": []}, parent=None)
ds = Dataset(logical_plan, ctx, stats)
splits = ds.split(num_splits, equal=True)
split_counts = [split.count() for split in splits]
assert len(split_counts) == num_splits
expected_block_size = total_rows // num_splits
# Check that all splits are the expected size.
assert all([count == expected_block_size for count in split_counts])
expected_total_rows = sum(split_counts)
# Check that the expected number of rows were dropped.
assert total_rows - expected_total_rows == total_rows % num_splits
# Check that all rows are unique (content check).
split_rows = [row for split in splits for row in split.take(total_rows)]
assert len(set(extract_values("id", split_rows))) == len(split_rows)
def test_equal_split_balanced_grid(ray_start_regular_shared_2_cpus):
# Tests balanced equal splitting over a grid of configurations.
# Grid: num_blocks x num_splits x num_rows_block_1 x ... x num_rows_block_n
seed = int(time.time())
print(f"Seeding RNG for test_equal_split_balanced_grid with: {seed}")
random.seed(seed)
max_num_splits = 15
num_splits_samples = 3
max_num_blocks = 50
max_num_rows_per_block = 100
num_blocks_samples = 3
block_sizes_samples = 3
for num_splits in np.random.randint(2, max_num_splits + 1, size=num_splits_samples):
for num_blocks in np.random.randint(
1, max_num_blocks + 1, size=num_blocks_samples
):
block_sizes_list = [
np.random.randint(1, max_num_rows_per_block + 1, size=num_blocks)
for _ in range(block_sizes_samples)
]
for block_sizes in block_sizes_list:
if sum(block_sizes) < num_splits:
min_ = math.ceil(num_splits / num_blocks)
block_sizes = np.random.randint(
min_, max_num_rows_per_block + 1, size=num_blocks
)
_test_equal_split_balanced(block_sizes, num_splits)
def test_split_small(ray_start_regular_shared_2_cpus):
x = [Counter.remote() for _ in range(4)]
data = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
fail = []
@ray.remote(num_cpus=0)
def take(s):
return extract_values("item", s.take())
for m in [1, 3]:
for n in [1, 3]:
for locality_hints in [None, x[:n]]:
for equal in [True, False]:
print("Testing", m, n, equal, locality_hints)
ds = ray.data.from_items(data, override_num_blocks=m)
splits = ds.split(n, equal=equal, locality_hints=locality_hints)
assert len(splits) == n
outs = ray.get([take.remote(s) for s in splits])
out = []
for r in outs:
out.extend(r)
if equal:
lens = set([len(s) for s in outs]) # noqa
limit = len(data) - (len(data) % n)
allowed = [limit]
# Allow for some pipelining artifacts.
print(len(out), len(set(out)), allowed)
if (
len(out) not in allowed
or len(set(out)) != len(out)
# TODO(ekl) we should be able to enable this check, but
# there are some edge condition bugs in split.
# or len(lens) != 1
):
print("FAIL", m, n, equal, locality_hints)
fail.append((m, n, equal, locality_hints))
else:
if sorted(out) != data:
print("FAIL", m, n, equal, locality_hints)
fail.append((m, n, equal, locality_hints))
assert not fail, fail
def test_split_at_indices_simple(ray_start_regular_shared_2_cpus, restore_data_context):
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
ds = ray.data.range(10, override_num_blocks=3)
with pytest.raises(ValueError):
ds.split_at_indices([])
with pytest.raises(ValueError):
ds.split_at_indices([-1])
with pytest.raises(ValueError):
ds.split_at_indices([3, 1])
splits = ds.split_at_indices([5])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
splits = ds.split_at_indices([2, 5])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1], [2, 3, 4], [5, 6, 7, 8, 9]]
splits = ds.split_at_indices([2, 5, 5, 100])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1], [2, 3, 4], [], [5, 6, 7, 8, 9], []]
splits = ds.split_at_indices([100])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], []]
splits = ds.split_at_indices([0])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]]
@pytest.mark.parametrize("num_blocks", list(range(1, 20)) + [25, 40])
@pytest.mark.parametrize(
"indices",
[
# Two-splits.
[5],
[10],
[15],
# Three-splits.
[5, 12],
[1, 18],
[9, 10],
# Misc.
[3, 10, 17],
[2, 4, 11, 12, 19],
list(range(20)),
list(range(0, 20, 2)),
# Empty splits.
[10, 10],
[5, 10, 10, 15],
# Out-of-bounds.
[25],
[7, 11, 23, 33],
],
)
def test_split_at_indices_coverage(
ray_start_regular_shared_2_cpus, num_blocks, indices, restore_data_context
):
# Test that split_at_indices() creates the expected splits on a set of partition and
# indices configurations.
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
ds = ray.data.range(20, override_num_blocks=num_blocks)
splits = ds.split_at_indices(indices)
r = [extract_values("id", s.sort("id").take_all()) for s in splits]
# Use np.array_split() semantics as our correctness ground-truth.
assert r == [arr.tolist() for arr in np.array_split(list(range(20)), indices)]
@pytest.mark.parametrize("num_blocks", [1, 3, 5, 10])
@pytest.mark.parametrize(
"indices",
[
[2], # Single split
[1, 3], # Two splits
[0, 2, 4], # Three splits
[1, 2, 3, 4], # Four splits
[1, 2, 3, 4, 7], # Five splits
[1, 2, 3, 4, 6, 9], # Six splits
]
+ [
list(x) for x in itertools.combinations_with_replacement([1, 3, 4], 2)
] # Selected two-split cases
+ [
list(x) for x in itertools.combinations_with_replacement([0, 2, 4], 3)
], # Selected three-split cases
)
def test_split_at_indices_coverage_complete(
ray_start_regular_shared_2_cpus, num_blocks, indices, restore_data_context
):
# NOTE: It's critical to preserve ordering for assertions in this test to work
DataContext.get_current().execution_options.preserve_order = True
# Test that split_at_indices() creates the expected splits on a set of partition and
# indices configurations.
ds = ray.data.range(10, override_num_blocks=num_blocks)
splits = ds.split_at_indices(indices)
r = [extract_values("id", s.take_all()) for s in splits]
# Use np.array_split() semantics as our correctness ground-truth.
assert r == [arr.tolist() for arr in np.array_split(list(range(10)), indices)]
def test_split_proportionately(ray_start_regular_shared_2_cpus):
ds = ray.data.range(10, override_num_blocks=3)
with pytest.raises(ValueError):
ds.split_proportionately([])
with pytest.raises(ValueError):
ds.split_proportionately([-1])
with pytest.raises(ValueError):
ds.split_proportionately([0])
with pytest.raises(ValueError):
ds.split_proportionately([1])
with pytest.raises(ValueError):
ds.split_proportionately([0.5, 0.5])
splits = ds.split_proportionately([0.5])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
splits = ds.split_proportionately([0.2, 0.3])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1], [2, 3, 4], [5, 6, 7, 8, 9]]
splits = ds.split_proportionately([0.2, 0.3, 0.3])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1], [2, 3, 4], [5, 6, 7], [8, 9]]
splits = ds.split_proportionately([0.98, 0.01])
r = [extract_values("id", s.take()) for s in splits]
assert r == [[0, 1, 2, 3, 4, 5, 6, 7], [8], [9]]
with pytest.raises(ValueError):
ds.split_proportionately([0.90] + ([0.001] * 90))
def test_split(ray_start_regular_shared_2_cpus):
ds = ray.data.range(20, override_num_blocks=10)
assert ds._logical_plan.initial_num_blocks() == 10
assert ds.sum() == 190
assert ds._block_num_rows() == [2] * 10
datasets = ds.split(5)
assert [2] * 5 == [len(dataset._execute().blocks) for dataset in datasets]
assert 190 == sum([dataset.sum("id") for dataset in datasets])
datasets = ds.split(3)
assert [4, 3, 3] == [len(dataset._execute().blocks) for dataset in datasets]
assert 190 == sum([dataset.sum("id") for dataset in datasets])
datasets = ds.split(1)
assert [10] == [len(dataset._execute().blocks) for dataset in datasets]
assert 190 == sum([dataset.sum("id") for dataset in datasets])
datasets = ds.split(10)
assert [1] * 10 == [len(dataset._execute().blocks) for dataset in datasets]
assert 190 == sum([dataset.sum("id") for dataset in datasets])
datasets = ds.split(11)
assert [1] * 10 + [0] == [len(dataset._execute().blocks) for dataset in datasets]
assert 190 == sum([dataset.sum("id") or 0 for dataset in datasets])
def test_split_hints(ray_start_regular_shared_2_cpus):
@ray.remote
class Actor(object):
def __init__(self):
pass
def assert_split_assignment(
block_node_ids: list, actor_node_ids: list, expected_split_result: list
):
"""Helper function to setup split hints test.
Args:
block_node_ids: a list of blocks with their locations. For
example ["node1", "node2"] represents two blocks with
"node1", "node2" as their location respectively.
actor_node_ids: a list of actors with their locations. For
example ["node1", "node2"] represents two actors with
"node1", "node2" as their location respectively.
expected_split_result: a list of allocation result, each entry
in the list stores the block_index in the split dataset.
For example, [[0, 1], [2]] represents the split result has
two datasets, datasets[0] contains block 0 and 1; and
datasets[1] contains block 2.
"""
num_blocks = len(block_node_ids)
ds = ray.data.range(num_blocks, override_num_blocks=num_blocks).materialize()
bundles = ds.iter_internal_ref_bundles()
blocks = _ref_bundles_iterator_to_block_refs_list(bundles)
assert len(block_node_ids) == len(blocks)
actors = [Actor.remote() for i in range(len(actor_node_ids))]
with patch("ray.experimental.get_object_locations") as location_mock:
with patch("ray._private.state.actors") as state_mock:
block_locations = {}
for i, node_id in enumerate(block_node_ids):
if node_id:
block_locations[blocks[i]] = {"node_ids": [node_id]}
location_mock.return_value = block_locations
actor_state = {}
for i, node_id in enumerate(actor_node_ids):
actor_state[actors[i]._actor_id.hex()] = {
"Address": {"NodeID": node_id}
}
state_mock.return_value = actor_state
datasets = ds.split(len(actors), locality_hints=actors)
assert len(datasets) == len(actors)
for i in range(len(actors)):
assert {blocks[j] for j in expected_split_result[i]} == set(
_ref_bundles_iterator_to_block_refs_list(
datasets[i].iter_internal_ref_bundles()
)
)
assert_split_assignment(
["node2", "node1", "node1"], ["node1", "node2"], [[1, 2], [0]]
)
assert_split_assignment(
["node1", "node1", "node1"], ["node1", "node2"], [[2, 1], [0]]
)
assert_split_assignment(["node2", "node2", None], ["node1", "node2"], [[0, 2], [1]])
assert_split_assignment(["node2", "node2", None], [None, None], [[2, 1], [0]])
assert_split_assignment(
["n1", "n2", "n3", "n1", "n2"], ["n1", "n2"], [[0, 2, 3], [1, 4]]
)
assert_split_assignment(["n1", "n2"], ["n1", "n2", "n3"], [[0], [1], []])
# perfect split:
#
# split 300 blocks
# with node_ids interleaving between "n0", "n1", "n2"
#
# to 3 actors
# with has node_id "n1", "n2", "n0"
#
# expect that block 1, 4, 7... are assigned to actor with node_id n1
# block 2, 5, 8... are assigned to actor with node_id n2
# block 0, 3, 6... are assigned to actor with node_id n0
assert_split_assignment(
["n0", "n1", "n2"] * 100,
["n1", "n2", "n0"],
[range(1, 300, 3), range(2, 300, 3), range(0, 300, 3)],
)
# even split regardless of locality:
#
# split 301 blocks
# with block 0 to block 50 on "n0",
# block 51 to block 300 on "n1"
#
# to 3 actors
# with node_ids "n1", "n2", "n0"
#
# expect that block 200 to block 300 are assigned to actor with node_id n1
# block 100 to block 199 are assigned to actor with node_id n2
# block 0 to block 99 are assigned to actor with node_id n0
assert_split_assignment(
["n0"] * 50 + ["n1"] * 251,
["n1", "n2", "n0"],
[range(200, 301), range(100, 200), list(range(0, 50)) + list(range(50, 100))],
)
def test_generate_valid_indices():
assert [1, 2, 3] == _generate_valid_indices([10], [1, 2, 3])
assert [1, 2, 2] == _generate_valid_indices([1, 1], [1, 2, 3])
def test_generate_per_block_split_indices():
assert [[1], [1, 2], [], []] == _generate_per_block_split_indices(
[3, 3, 3, 1], [1, 4, 5]
)
assert [[3], [], [], [1, 1]] == _generate_per_block_split_indices(
[3, 3, 3, 1], [3, 10, 10]
)
assert [[], [], [], []] == _generate_per_block_split_indices([3, 3, 3, 1], [])
def _create_meta(num_rows):
return BlockMetadata(
num_rows=num_rows,
size_bytes=None,
input_files=None,
exec_stats=None,
)
def _create_block_and_metadata(data: Any) -> Tuple[ObjectRef[Block], BlockMetadata]:
block = pd.DataFrame({"id": data})
metadata = BlockAccessor.for_block(block).get_metadata()
return (ray.put(block), metadata)
def _create_bundle(blocks: List[List[Any]]) -> RefBundle:
schema = BlockAccessor.for_block(pd.DataFrame({"id": []})).schema()
return RefBundle(
[BlockEntry(*_create_block_and_metadata(block)) for block in blocks],
owns_blocks=True,
schema=schema,
)
def _create_blocks_with_metadata(blocks):
# Returns the legacy 2-tuple shape (BlockPartition) consumed by the
# split helpers in ``ray.data._internal.split``.
bundle = _create_bundle(blocks)
return [(entry.ref, entry.metadata) for entry in bundle.blocks]
def test_split_single_block(ray_start_regular_shared_2_cpus):
block = pd.DataFrame({"id": [1, 2, 3]})
metadata = _create_meta(3)
results = ray.get(
ray.remote(_split_single_block)
.options(num_returns=2)
.remote(234, block, metadata, [])
)
block_id, meta = results[0]
blocks = results[1:]
assert 234 == block_id
assert len(blocks) == 1
assert list(blocks[0]["id"]) == [1, 2, 3]
assert meta[0].num_rows == 3
results = ray.get(
ray.remote(_split_single_block)
.options(num_returns=3)
.remote(234, block, metadata, [1])
)
block_id, meta = results[0]
blocks = results[1:]
assert 234 == block_id
assert len(blocks) == 2
assert list(blocks[0]["id"]) == [1]
assert meta[0].num_rows == 1
assert list(blocks[1]["id"]) == [2, 3]
assert meta[1].num_rows == 2
results = ray.get(
ray.remote(_split_single_block)
.options(num_returns=6)
.remote(234, block, metadata, [0, 1, 1, 3])
)
block_id, meta = results[0]
blocks = results[1:]
assert 234 == block_id
assert len(blocks) == 5
assert list(blocks[0]["id"]) == []
assert list(blocks[1]["id"]) == [1]
assert list(blocks[2]["id"]) == []
assert list(blocks[3]["id"]) == [2, 3]
assert list(blocks[4]["id"]) == []
block = pd.DataFrame({"id": []})
metadata = _create_meta(0)
results = ray.get(
ray.remote(_split_single_block)
.options(num_returns=3)
.remote(234, block, metadata, [0])
)
block_id, meta = results[0]
blocks = results[1:]
assert 234 == block_id
assert len(blocks) == 2
assert list(blocks[0]["id"]) == []
assert list(blocks[1]["id"]) == []
def test_drop_empty_block_split():
assert [1, 2] == _drop_empty_block_split([0, 1, 2, 3], 3)
assert [1, 2] == _drop_empty_block_split([1, 1, 2, 2], 3)
assert [] == _drop_empty_block_split([0], 0)
def verify_splits(splits, blocks_by_split):
assert len(splits) == len(blocks_by_split)
for blocks, (block_refs, metas) in zip(blocks_by_split, splits):
assert len(blocks) == len(block_refs)
assert len(blocks) == len(metas)
for block, block_ref, meta in zip(blocks, block_refs, metas):
assert list(ray.get(block_ref)["id"]) == block
assert meta.num_rows == len(block)
def test_generate_global_split_results(ray_start_regular_shared_2_cpus):
inputs = [
_create_block_and_metadata([1]),
_create_block_and_metadata([2, 3]),
_create_block_and_metadata([4]),
]
splits = list(zip(*_generate_global_split_results(iter(inputs), [1, 2, 1])))
verify_splits(splits, [[[1]], [[2, 3]], [[4]]])
splits = list(zip(*_generate_global_split_results(iter(inputs), [3, 1])))
verify_splits(splits, [[[1], [2, 3]], [[4]]])
splits = list(zip(*_generate_global_split_results(iter(inputs), [3, 0, 1])))
verify_splits(splits, [[[1], [2, 3]], [], [[4]]])
inputs = []
splits = list(zip(*_generate_global_split_results(iter(inputs), [0, 0])))
verify_splits(splits, [[], []])
def test_private_split_at_indices(ray_start_regular_shared_2_cpus):
inputs = _create_blocks_with_metadata([])
splits = list(zip(*_split_at_indices(inputs, [0], True)))
verify_splits(splits, [[], []])
splits = list(zip(*_split_at_indices(inputs, [], True)))
verify_splits(splits, [[]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [1], True)))
verify_splits(splits, [[[1]], [[2, 3], [4]]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [2], True)))
verify_splits(splits, [[[1], [2]], [[3], [4]]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [1], True)))
verify_splits(splits, [[[1]], [[2, 3], [4]]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [2, 2], True)))
verify_splits(splits, [[[1], [2]], [], [[3], [4]]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [], True)))
verify_splits(splits, [[[1], [2, 3], [4]]])
inputs = _create_blocks_with_metadata([[1], [2, 3], [4]])
splits = list(zip(*_split_at_indices(inputs, [0, 4], True)))
verify_splits(splits, [[], [[1], [2, 3], [4]], []])
def equalize_helper(input_block_lists: List[List[List[Any]]]):
result = _equalize(
[_create_bundle(block_list) for block_list in input_block_lists],
owned_by_consumer=True,
)
result_block_lists = []
for bundle in result:
block_list = []
for block_ref in bundle.block_refs:
block = ray.get(block_ref)
block_accessor = BlockAccessor.for_block(block)
block_list.append(list(block_accessor.to_default()["id"]))
result_block_lists.append(block_list)
return result_block_lists
def verify_equalize_result(input_block_lists, expected_block_lists):
result_block_lists = equalize_helper(input_block_lists)
assert result_block_lists == expected_block_lists
def test_equalize(ray_start_regular_shared_2_cpus):
verify_equalize_result([], [])
verify_equalize_result([[]], [[]])
verify_equalize_result([[[1]], []], [[], []])
verify_equalize_result([[[1], [2, 3]], [[4]]], [[[1], [2]], [[4], [3]]])
verify_equalize_result([[[1], [2, 3]], []], [[[1]], [[2]]])
verify_equalize_result(
[[[1], [2, 3], [4, 5]], [[6]], []], [[[1], [2]], [[6], [3]], [[4, 5]]]
)
verify_equalize_result(
[[[1, 2, 3], [4, 5]], [[6]], []], [[[4, 5]], [[6], [1]], [[2, 3]]]
)
def test_equalize_randomized(ray_start_regular_shared_2_cpus):
# verify the entries in the splits are in the range of 0 .. num_rows,
# unique, and the total number matches num_rows if exact_num == True.
def assert_unique_and_inrange(splits, num_rows, exact_num=False):
unique_set = set()
for split in splits:
for block in split:
for entry in block:
assert entry not in unique_set
assert entry >= 0 and entry < num_rows
unique_set.add(entry)
if exact_num:
assert len(unique_set) == num_rows
# verify that splits are equalized.
def assert_equal_split(splits, num_rows, num_split):
split_size = num_rows // num_split
for split in splits:
assert len((list(itertools.chain.from_iterable(split)))) == split_size
# create randomized splits contains entries from 0 ... num_rows.
def random_split(num_rows, num_split):
split_point = [int(random.random() * num_rows) for _ in range(num_split - 1)]
split_index_helper = [0] + sorted(split_point) + [num_rows]
splits = []
for i in range(1, len(split_index_helper)):
split_start = split_index_helper[i - 1]
split_end = split_index_helper[i]
num_entries = split_end - split_start
split = []
num_block_split = int(random.random() * num_entries)
block_split_point = [
split_start + int(random.random() * num_entries)
for _ in range(num_block_split)
]
block_index_helper = [split_start] + sorted(block_split_point) + [split_end]
for j in range(1, len(block_index_helper)):
split.append(
list(range(block_index_helper[j - 1], block_index_helper[j]))
)
splits.append(split)
assert_unique_and_inrange(splits, num_rows, exact_num=True)
return splits
for i in range(100):
num_rows = int(random.random() * 100)
num_split = int(random.random() * 10) + 1
input_splits = random_split(num_rows, num_split)
print(input_splits)
equalized_splits = equalize_helper(input_splits)
assert_unique_and_inrange(equalized_splits, num_rows)
assert_equal_split(equalized_splits, num_rows, num_split)
def test_train_test_split(ray_start_regular_shared_2_cpus):
ds = ray.data.range(8)
# float
train, test = ds.train_test_split(test_size=0.25)
assert extract_values("id", train.take()) == [0, 1, 2, 3, 4, 5]
assert extract_values("id", test.take()) == [6, 7]
# int
train, test = ds.train_test_split(test_size=2)
assert extract_values("id", train.take()) == [0, 1, 2, 3, 4, 5]
assert extract_values("id", test.take()) == [6, 7]
# shuffle
train, test = ds.train_test_split(test_size=0.25, shuffle=True, seed=1)
assert extract_values("id", train.take()) == [7, 4, 6, 0, 5, 2]
assert extract_values("id", test.take()) == [1, 3]
# error handling
with pytest.raises(TypeError):
ds.train_test_split(test_size=[1])
with pytest.raises(ValueError):
ds.train_test_split(test_size=-1)
with pytest.raises(ValueError):
ds.train_test_split(test_size=0)
with pytest.raises(ValueError):
ds.train_test_split(test_size=1.1)
with pytest.raises(ValueError):
ds.train_test_split(test_size=9)
def test_train_test_split_stratified(ray_start_regular_shared_2_cpus):
# Test basic stratification with simple dataset
data = [
{"id": 0, "label": "A"},
{"id": 1, "label": "A"},
{"id": 2, "label": "B"},
{"id": 3, "label": "B"},
{"id": 4, "label": "C"},
{"id": 5, "label": "C"},
]
ds = ray.data.from_items(data)
# Test stratified split
train, test = ds.train_test_split(test_size=0.5, stratify="label")
# Check that we have the right number of samples
assert train.count() == 3
assert test.count() == 3
# Check that class proportions are preserved
train_labels = [row["label"] for row in train.take()]
test_labels = [row["label"] for row in test.take()]
train_label_counts = {label: train_labels.count(label) for label in ["A", "B", "C"]}
test_label_counts = {label: test_labels.count(label) for label in ["A", "B", "C"]}
# Each class should have exactly 1 sample in each split
assert train_label_counts == {"A": 1, "B": 1, "C": 1}
assert test_label_counts == {"A": 1, "B": 1, "C": 1}
def test_train_test_split_shuffle_stratify_error(ray_start_regular_shared_2_cpus):
# Test that shuffle=True and stratify cannot be used together
data = [
{"id": 0, "label": "A"},
{"id": 1, "label": "A"},
{"id": 2, "label": "B"},
{"id": 3, "label": "B"},
]
ds = ray.data.from_items(data)
# Test that combining shuffle=True and stratify raises ValueError
with pytest.raises(
ValueError, match="Cannot specify both 'shuffle=True' and 'stratify'"
):
ds.train_test_split(test_size=0.5, shuffle=True, stratify="label")
def test_train_test_split_stratified_imbalanced(ray_start_regular_shared_2_cpus):
# Test stratified split with imbalanced class distribution
data = [
{"id": 0, "label": "A"},
{"id": 1, "label": "A"},
{"id": 2, "label": "A"},
{"id": 3, "label": "A"},
{"id": 4, "label": "A"},
{"id": 5, "label": "A"}, # 6 samples of class A
{"id": 6, "label": "B"},
{"id": 7, "label": "B"}, # 2 samples of class B
{"id": 8, "label": "C"}, # 1 sample of class C
]
ds = ray.data.from_items(data)
# Test with 0.3 test size
train, test = ds.train_test_split(test_size=0.3, stratify="label")
train_labels = [row["label"] for row in train.take()]
test_labels = [row["label"] for row in test.take()]
train_label_counts = {label: train_labels.count(label) for label in ["A", "B", "C"]}
test_label_counts = {label: test_labels.count(label) for label in ["A", "B", "C"]}
# Check proportions are maintained as closely as possible
# Class A: 6 samples -> test_count = int(6 * 0.3) = 1 -> train: 5, test: 1
# Class B: 2 samples -> test_count = int(2 * 0.3) = 0 -> train: 2, test: 0
# Class C: 1 sample -> test_count = int(1 * 0.3) = 0 -> train: 1, test: 0
assert train_label_counts["A"] == 5
assert test_label_counts["A"] == 1
assert train_label_counts["B"] == 2
assert test_label_counts["B"] == 0
assert train_label_counts["C"] == 1
assert test_label_counts["C"] == 0
def test_split_is_not_disruptive(ray_start_cluster):
ray.shutdown()
ds = ray.data.range(100, override_num_blocks=10).map_batches(lambda x: x)
def verify_integrity(splits):
for dss in splits:
for batch in dss.iter_batches():
pass
for batch in ds.iter_batches():
pass
# No block splitting invovled: split 10 even blocks into 2 groups.
verify_integrity(ds.split(2, equal=True))
# Block splitting invovled: split 10 even blocks into 3 groups.
verify_integrity(ds.split(3, equal=True))
# Same as above but having tranforms post converting to lazy.
verify_integrity(ds.map_batches(lambda x: x).split(2, equal=True))
verify_integrity(ds.map_batches(lambda x: x).split(3, equal=True))
# Same as above but having in-place tranforms post converting to lazy.
verify_integrity(ds.randomize_block_order().split(2, equal=True))
verify_integrity(ds.randomize_block_order().split(3, equal=True))
def test_streaming_train_test_split_hash(ray_start_regular_shared_2_cpus):
ds = ray.data.range(10000000, override_num_blocks=10)
ds_train, ds_test = ds.streaming_train_test_split(
test_size=0.2, split_type="hash", hash_column="id"
)
np.testing.assert_almost_equal(float(ds_train.count()) / 10000000.0, 0.8, decimal=3)
np.testing.assert_almost_equal(float(ds_test.count()) / 10000000.0, 0.2, decimal=3)
# Check if train and test are disjoint
assert (
ds_train.join(ds_test, join_type="inner", on=("id",), num_partitions=1).count()
== 0
)
@pytest.mark.parametrize("seed", [None, 42])
def test_streaming_train_test_split_random(ray_start_regular_shared_2_cpus, seed):
ds = ray.data.range(10000000, override_num_blocks=10)
ds_train, ds_test = ds.streaming_train_test_split(
test_size=0.2, split_type="random", seed=seed
)
np.testing.assert_almost_equal(float(ds_train.count()) / 10000000.0, 0.8, decimal=3)
np.testing.assert_almost_equal(float(ds_test.count()) / 10000000.0, 0.2, decimal=3)
# Check if train and test are disjoint
assert (
ds_train.join(ds_test, join_type="inner", on=("id",), num_partitions=1).count()
== 0
)
@pytest.mark.parametrize(
"test_size,split_type,hash_column,seed,error_msg",
[
(0.2, "hash", None, None, "hash_column is required for hash split"),
(0.2, "hash", "id", 42, "seed is not supported for hash split"),
(0, "hash", "id", None, "test_size must be between 0 and 1"),
(1, "hash", "id", None, "test_size must be between 0 and 1"),
(0.2, "random", "id", None, "hash_column is not supported for random split"),
(0, "random", None, None, "test_size must be between 0 and 1"),
(1, "random", None, None, "test_size must be between 0 and 1"),
(0.2, "unknown", "id", None, "Invalid split type: unknown"),
],
)
def test_streaming_train_test_split_wrong_params(
ray_start_regular_shared_2_cpus, test_size, split_type, hash_column, seed, error_msg
):
ds = ray.data.range(10)
with pytest.raises(ValueError, match=error_msg):
ds.streaming_train_test_split(
test_size=test_size,
split_type=split_type,
hash_column=hash_column,
seed=seed,
)
@pytest.mark.parametrize("prefetch_batches", [0, 2])
def test_streaming_split_reports_and_clears_prefetched_bytes(
ray_start_regular_shared_2_cpus, prefetch_batches
):
"""Test streaming_split reports and clears prefetched bytes.
Verifies that:
1. Prefetched bytes are tracked during iteration
2. When StopIteration is raised in SplitCoordinator.get(), the
prefetched bytes are cleared to avoid stale backpressure data
"""
ds = ray.data.range(20, override_num_blocks=4)
splits = ds.streaming_split(2, equal=True)
# Get the coordinator actor to check prefetched bytes
coord = splits[0]._coord_actor
results = []
def consume(split, idx):
count = 0
for batch in split.iter_batches(
batch_size=5, prefetch_batches=prefetch_batches
):
count += len(batch["id"])
results.append((idx, count))
threads = [
threading.Thread(target=consume, args=(splits[0], 0)),
threading.Thread(target=consume, args=(splits[1], 1)),
]
for t in threads:
t.start()
for t in threads:
t.join()
# Verify both splits consumed all data (epoch completed via StopIteration)
total_rows = sum(r[1] for r in results)
assert total_rows == 20, f"Expected 20 total rows, got {total_rows}"
# Verify client prefetched bytes are cleared after epoch end
# (cleared when StopIteration is raised in SplitCoordinator.get())
client_bytes = ray.get(coord.get_client_prefetched_bytes.remote())
for split_idx, bytes_val in client_bytes.items():
assert (
bytes_val == 0
), f"Split {split_idx} has stale prefetched bytes: {bytes_val}"
# Run a second epoch to verify cleared bytes don't cause issues
results.clear()
threads = [
threading.Thread(target=consume, args=(splits[0], 0)),
threading.Thread(target=consume, args=(splits[1], 1)),
]
for t in threads:
t.start()
for t in threads:
t.join()
# Second epoch should also consume all data
total_rows = sum(r[1] for r in results)
assert total_rows == 20, f"Second epoch: expected 20 rows, got {total_rows}"
# Verify prefetched bytes cleared again after second epoch
client_bytes = ray.get(coord.get_client_prefetched_bytes.remote())
for split_idx, bytes_val in client_bytes.items():
assert (
bytes_val == 0
), f"Split {split_idx} stale bytes after 2nd epoch: {bytes_val}"
def test_streaming_splits_schema_access(ray_start_regular_shared_2_cpus):
ds = ray.data.range(20, override_num_blocks=4)
iter_1, iter_2 = ds.streaming_split(2)
expected_schema = pa.schema([pa.field("id", pa.int64())])
assert expected_schema.equals(iter_1.schema().base_schema)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))