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

531 lines
18 KiB
Python

import sys
import threading
from typing import Dict
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import torch
import ray
if sys.version_info <= (3, 12):
# Skip this test for Python 3.12+ due to to incompatibility tensorflow
import tensorflow as tf
def build_model():
import tensorflow as tf
model = tf.keras.Sequential(
[
tf.keras.layers.InputLayer(input_shape=()),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(1),
]
)
model.compile(optimizer="sgd", loss="mse")
return model
def test_basic_dataset(ray_start_regular_shared):
ds = ray.data.range(100)
it = ds.iterator()
for _ in range(2):
result = []
for batch in it.iter_batches():
batch = batch["id"]
result += batch.tolist()
assert result == list(range(100))
# TODO(swang): This check currently fails nondeterministically because
# stats are stored in an actor.
# https://github.com/ray-project/ray/issues/31571
# assert it.stats() == ds.stats()
def test_basic_dataset_multi_use_iterator(ray_start_regular_shared):
"""Tests that the iterable outputted by `iter_batches` can be used
multiple times."""
ds = ray.data.range(100)
it = ds.iterator().iter_batches()
for _ in range(2):
result = []
for batch in it:
batch = batch["id"]
result += batch.tolist()
assert result == list(range(100))
def test_basic_dataset_preemption(ray_start_regular_shared):
"""Tests that the iterable outputted by ``iter_batches``
can be used multiple times even if it is preempted during iteration."""
ds = ray.data.range(100)
it = ds.iterator().iter_batches(batch_size=50)
for _ in range(2):
result = []
for i, batch in enumerate(it):
if i > 0:
break
batch = batch["id"]
result += batch.tolist()
assert result == list(range(50))
def test_iter_batches_early_exit_shuts_down_executor(ray_start_regular_shared):
"""Tests that breaking out of ``iter_batches`` early shuts down the
streaming executor deterministically. Otherwise the executor's worker
thread keeps producing blocks that pile up in the object store and
holds resources that can starve other datasets (e.g., a validation
dataset waiting to run)."""
ds = ray.data.range(1000, override_num_blocks=20)
it = ds.iterator()
for i, _ in enumerate(it.iter_batches(batch_size=10)):
if i == 0:
break
executor = ds._current_executor
assert executor is not None
assert executor._shutdown is True
def test_iter_batches_early_break_flushes_metrics(ray_start_regular_shared):
"""Tests that an early ``break`` in the training loop still records
``iter_total_s`` and flushes metrics via ``update_iteration_metrics``."""
from ray.data._internal.stats import _StatsManager
ds = ray.data.range(100, override_num_blocks=5)
it = ds.iterator()
captured_stats = []
orig = _StatsManager.update_iteration_metrics
def spy(stats, dataset_tag):
captured_stats.append(stats)
return orig(stats, dataset_tag)
with patch.object(_StatsManager, "update_iteration_metrics", spy):
for i, _ in enumerate(it.iter_batches(batch_size=10)):
if i == 0:
break
# finally block should have called update_iteration_metrics
assert len(captured_stats) > 0
# iter_total_s was recorded even on early break
assert captured_stats[-1].iter_total_s.get() > 0
assert captured_stats[-1].iter_batches_total > 0
def test_iter_batches_full_iteration_shuts_down_executor(ray_start_regular_shared):
"""Tests that fully iterating ``iter_batches`` shuts down the
streaming executor (regression guard for the early-exit cleanup
path, which adds an idempotent shutdown to the iterator)."""
ds = ray.data.range(100, override_num_blocks=5)
it = ds.iterator()
for _ in it.iter_batches(batch_size=10):
pass
executor = ds._current_executor
assert executor is not None
assert executor._shutdown is True
def test_iter_batches_exception_shuts_down_executor(ray_start_regular_shared):
"""Tests that an exception raised inside the user's iteration loop
still triggers executor shutdown via the iterator's ``finally``."""
ds = ray.data.range(1000, override_num_blocks=20)
it = ds.iterator()
class _Sentinel(Exception):
pass
with pytest.raises(_Sentinel):
for _ in it.iter_batches(batch_size=10):
raise _Sentinel()
executor = ds._current_executor
assert executor is not None
assert executor._shutdown is True
def test_iter_batches_close_on_held_iterator_shuts_down_executor(
ray_start_regular_shared,
):
"""Tests that ``it.close()`` shuts down the executor when the caller
holds an explicit reference to the iterator. Without ``close()``,
Python defers cleanup until the reference is dropped — ``break``
inside the for-loop wouldn't fire ``GeneratorExit`` on a held
reference. Some libraries (e.g. PyTorch Lightning's batch fetchers)
keep an ``iter()`` reference internally; this is the documented
eager-cleanup escape hatch."""
ds = ray.data.range(1000, override_num_blocks=20)
batches = ds.iterator().iter_batches(batch_size=10)
it = iter(batches)
next(it)
it.close()
executor = ds._current_executor
assert executor is not None
assert executor._shutdown is True
def test_basic_dataset_iter_rows(ray_start_regular_shared):
ds = ray.data.range(100)
it = ds.iterator()
for _ in range(2):
result = []
for row in it.iter_rows():
row = row["id"]
result.append(row)
assert result == list(range(100))
# TODO(swang): This check currently fails nondeterministically because
# stats are stored in an actor.
# https://github.com/ray-project/ray/issues/31571
# assert it.stats() == ds.stats()
@pytest.mark.parametrize("include_additional_columns", [False, True])
def test_tf_conversion(ray_start_regular_shared, include_additional_columns):
ds = ray.data.range(5)
it = ds.iterator()
if include_additional_columns:
tf_dataset = it.to_tf("id", "id", additional_columns="id")
else:
tf_dataset = it.to_tf("id", "id")
for i, row in enumerate(tf_dataset):
assert all(row[0] == i)
assert all(row[1] == i)
assert isinstance(row[0], tf.Tensor)
assert isinstance(row[1], tf.Tensor)
if include_additional_columns:
assert all(row[2] == i)
assert isinstance(row[2], tf.Tensor)
@pytest.mark.parametrize("include_additional_columns", [False, True])
def test_tf_e2e(ray_start_regular_shared, include_additional_columns):
ds = ray.data.range(5)
it = ds.iterator()
model = build_model()
if include_additional_columns:
model.fit(it.to_tf("id", "id", additional_columns="id"), epochs=3)
else:
model.fit(it.to_tf("id", "id"), epochs=3)
def test_torch_conversion(ray_start_regular_shared):
ds = ray.data.range(5)
it = ds.iterator()
it._iter_batches = MagicMock()
for batch in it.iter_torch_batches():
assert isinstance(batch["id"], torch.Tensor)
assert batch["id"].tolist() == list(range(5))
# When collate_fn is not specified, check that the default
# `_collate_fn` (handles formatting and Tensor creation)
# and `_finalize_fn` (handles host to device data transfer)
# are used in `DataIterator.iter_batches()`.
iter_batches_calls_kwargs = [a.kwargs for a in it._iter_batches.call_args_list]
assert all(
callable(kwargs["_collate_fn"]) and callable(kwargs["_finalize_fn"])
for kwargs in iter_batches_calls_kwargs
), iter_batches_calls_kwargs
def test_torch_multi_use_iterator(ray_start_regular_shared):
"""Tests that the iterator outputted by `iter_torch_batches` can be used
multiple times."""
ds = ray.data.range(5)
it = ds.iterator().iter_torch_batches()
for _ in range(2):
for batch in it:
assert isinstance(batch["id"], torch.Tensor)
assert batch["id"].tolist() == list(range(5))
def test_torch_conversion_collate_fn(ray_start_regular_shared):
def collate_fn(batch: Dict[str, np.ndarray]):
return torch.as_tensor(batch["id"] + 5)
ds = ray.data.range(5)
it = ds.iterator()
for batch in it.iter_torch_batches(collate_fn=collate_fn):
assert isinstance(batch, torch.Tensor)
assert batch.tolist() == list(range(5, 10))
# Should fail.
with pytest.raises(ValueError):
for batch in it.iter_torch_batches(collate_fn=collate_fn, dtypes=torch.float32):
assert isinstance(batch, torch.Tensor)
assert batch.tolist() == list(range(5, 10))
with pytest.raises(ValueError):
for batch in it.iter_torch_batches(collate_fn=collate_fn, device="cpu"):
assert isinstance(batch, torch.Tensor)
assert batch.tolist() == list(range(5, 10))
# Test that we don't automatically set device if collate_fn is specified.
with patch("ray.train.torch.get_device", lambda: torch.device("cuda")):
devices = ray.train.torch.get_device()
assert devices.type == "cuda"
it._iter_batches = MagicMock()
for batch in it.iter_torch_batches(collate_fn=collate_fn):
assert batch.device.type == "cpu"
assert isinstance(batch, torch.Tensor)
assert batch.tolist() == list(range(5, 10))
# Check that _finalize_fn is always used in `DataIterator.iter_batches()`.
iter_batches_calls_kwargs = [a.kwargs for a in it._iter_batches.call_args_list]
assert all(
kwargs["_finalize_fn"] is not None for kwargs in iter_batches_calls_kwargs
), iter_batches_calls_kwargs
@pytest.fixture(params=["regular", "chunked"])
def null_array_table(request):
"""Fixture that returns a PyArrow table with either a regular or chunked null array."""
if request.param == "regular":
# Regular array
return pa.table({"fruit_apple": pa.array([None, None, None], type=pa.null())})
else:
# Chunked array
return pa.table(
{
"fruit_apple": pa.chunked_array(
[
pa.array([None], type=pa.null()),
pa.array([None, None], type=pa.null()),
]
)
}
)
@pytest.fixture
def image_dataset(ray_start_regular_shared):
"""Fixture that returns a Ray dataset with image-like columns."""
num_rows = 10
num_images = 8
image_size_flat = 224 * 224 * 3 # Flattened image size
rows = []
for _ in range(num_rows):
row = {}
for i in range(num_images):
row[f"image_{i}"] = np.random.randint(
0, 255, size=image_size_flat, dtype=np.uint8
)
rows.append(row)
table = pa.Table.from_pylist(rows)
return ray.data.from_arrow([table])
def test_torch_conversion_null_type(ray_start_regular_shared, null_array_table):
"""Test iter_torch_batches with a PyArrow table containing null type arrays."""
ds = ray.data.from_arrow(null_array_table)
it = ds.iterator()
for batch in it.iter_torch_batches():
assert isinstance(batch, dict)
assert "fruit_apple" in batch
assert isinstance(batch["fruit_apple"], torch.Tensor)
assert torch.isnan(batch["fruit_apple"]).all()
assert batch["fruit_apple"].shape == (3,)
@pytest.fixture
def collate_fns_with_and_without_threading():
"""Fixture that provides DefaultCollateFn with and without threading."""
from ray.data.collate_fn import DefaultCollateFn
collate_fn_with_threading = DefaultCollateFn(num_workers=4)
collate_fn_no_threading = DefaultCollateFn(num_workers=0)
return {
"with_threading": collate_fn_with_threading,
"without_threading": collate_fn_no_threading,
}
def _collect_batches(ds, collate_fn, batch_size=5):
"""Helper function to collect batches from iter_torch_batches."""
batches = []
for batch in ds.iterator().iter_torch_batches(
collate_fn=collate_fn, batch_size=batch_size
):
batches.append(batch)
return batches
def test_torch_conversion_default_collate_fn_threading(
ray_start_regular_shared,
image_dataset,
collate_fns_with_and_without_threading,
):
"""Test DefaultCollateFn with/without threading produces same results."""
ds = image_dataset
collate_fns = collate_fns_with_and_without_threading
batches_with_threading = _collect_batches(ds, collate_fns["with_threading"])
batches_no_threading = _collect_batches(ds, collate_fns["without_threading"])
# Verify results are the same
assert len(batches_with_threading) == len(batches_no_threading)
for b1, b2 in zip(batches_with_threading, batches_no_threading):
assert set(b1.keys()) == set(b2.keys())
for col in b1.keys():
assert len(b1[col]) == len(b2[col])
for t1, t2 in zip(b1[col], b2[col]):
assert torch.equal(t1, t2)
class TestToTorch:
def test_features_only(self, ray_start_regular_shared):
ds = ray.data.from_items([{"a": 1, "b": 2}, {"a": 3, "b": 4}])
it = ds.iterator().to_torch(batch_size=2)
features, labels = next(iter(it))
assert labels is None
assert isinstance(features, torch.Tensor)
assert features.shape == (2, 2)
def test_with_label_column(self, ray_start_regular_shared):
ds = ray.data.from_items([{"x": 1, "y": 10}, {"x": 2, "y": 20}])
it = ds.iterator().to_torch(label_column="y", batch_size=2)
features, labels = next(iter(it))
assert features.shape == (2, 1)
assert labels.shape == (2, 1)
assert sorted(labels.flatten().tolist()) == [10, 20]
def test_squeezed_label(self, ray_start_regular_shared):
ds = ray.data.from_items([{"x": 1, "y": 10}, {"x": 2, "y": 20}])
it = ds.iterator().to_torch(
label_column="y", batch_size=2, unsqueeze_label_tensor=False
)
features, labels = next(iter(it))
assert labels.shape == (2,)
def test_feature_columns_as_list_of_lists(self, ray_start_regular_shared):
ds = ray.data.from_pandas(pd.DataFrame({"a": [[1, 2], [3, 4], [5, 6]]}))
it = ds.iterator().to_torch(feature_columns=["a"], batch_size=2)
features, labels = next(iter(it))
assert labels is None
assert features.shape == (2, 1, 2)
def test_feature_columns_subset(self, ray_start_regular_shared):
ds = ray.data.from_items([{"a": 1, "b": 2, "c": 3}])
it = ds.iterator().to_torch(feature_columns=["a", "c"], batch_size=1)
features, labels = next(iter(it))
assert features.shape == (1, 2)
def test_feature_columns_as_dict(self, ray_start_regular_shared):
ds = ray.data.from_items([{"a": 1, "b": 2, "c": 3}])
it = ds.iterator().to_torch(
feature_columns={"group1": ["a"], "group2": ["b", "c"]},
batch_size=1,
)
features, labels = next(iter(it))
assert isinstance(features, dict)
assert set(features.keys()) == {"group1", "group2"}
assert features["group1"].shape == (1, 1)
assert features["group2"].shape == (1, 2)
def test_explicit_dtype(self, ray_start_regular_shared):
ds = ray.data.from_items([{"a": 1, "b": 2}])
it = ds.iterator().to_torch(feature_column_dtypes=torch.float32, batch_size=1)
features, _ = next(iter(it))
assert features.dtype == torch.float32
def test_returns_iterable_dataset(self, ray_start_regular_shared):
"""to_torch should return a torch IterableDataset."""
ds = ray.data.range(1)
result = ds.iterator().to_torch(batch_size=1)
assert isinstance(result, torch.utils.data.IterableDataset)
def test_to_torch_emits_deprecation_warning(ray_start_regular_shared):
with pytest.warns(DeprecationWarning):
ray.data.range(1).iterator().to_torch()
@pytest.mark.parametrize("should_equalize", [True, False])
def test_iterator_to_materialized_dataset(ray_start_regular_shared, should_equalize):
"""Tests that `DataIterator.materialize` fully consumes the
iterator and returns a `MaterializedDataset` view of the data
that can be used to interact with the full dataset
(e.g. load it all into memory)."""
ds = ray.data.range(10)
num_splits = 2
iters = ds.streaming_split(num_splits, equal=should_equalize)
def consume_in_parallel(fn):
runners = [
threading.Thread(target=fn, args=(it, i)) for i, it in enumerate(iters)
]
[r.start() for r in runners]
[r.join() for r in runners]
materialized_ds = {}
shard_data = {}
def materialize(it, i):
materialized_ds[i] = it.materialize()
def iter_batches(it, i):
data = []
for batch in it.iter_batches():
data.extend(batch["id"].tolist())
shard_data[i] = data
consume_in_parallel(materialize)
consume_in_parallel(iter_batches)
# Check that the materialized datasets contain the same data as the
# original iterators.
for i in range(num_splits):
assert sorted(materialized_ds[i].to_pandas()["id"].tolist()) == sorted(
shard_data[i]
)
if __name__ == "__main__":
import sys
if sys.version_info >= (3, 12):
# Skip this test for Python 3.12+ due to to incompatibility tensorflow
sys.exit(0)
sys.exit(pytest.main(["-v", __file__]))