import random from typing import Optional from unittest.mock import MagicMock import pytest import ray from ray import train from ray.data import DataIterator from ray.data._internal.execution.interfaces.execution_options import ( ExecutionOptions, ExecutionResources, ) from ray.tests.conftest import * # noqa from ray.train import DataConfig, ScalingConfig from ray.train.data_parallel_trainer import DataParallelTrainer @pytest.fixture def ray_start_4_cpus(): address_info = ray.init(num_cpus=4) yield address_info ray.shutdown() class TestBasic(DataParallelTrainer): def __init__( self, num_workers: int, expect_ds: bool, expect_sizes: Optional[dict], **kwargs ): def train_loop_per_worker(): data_shard = train.get_dataset_shard("train") assert isinstance(data_shard, DataIterator), data_shard for k, v in expect_sizes.items(): shard = train.get_dataset_shard(k) if v == -1: assert shard is None, shard else: count = 0 for batch in shard.iter_batches(): for arr in batch.values(): count += arr.size assert count == v, shard kwargs.pop("scaling_config", None) super().__init__( train_loop_per_worker=train_loop_per_worker, scaling_config=ScalingConfig(num_workers=num_workers), **kwargs, ) def test_basic(ray_start_4_cpus): ds = ray.data.range(10) # Single worker basic case. test = TestBasic( 1, True, {"train": 10, "test": 10}, datasets={"train": ds, "test": ds}, ) test.fit() # Single worker, no test ds. test = TestBasic(1, True, {"train": 10, "test": -1}, datasets={"train": ds}) test.fit() # Two workers, train and test split. test = TestBasic( 2, True, {"train": 5, "test": 5}, datasets={"train": ds, "test": ds} ) test.fit() # Two workers, both split. test = TestBasic( 2, True, {"train": 5, "test": 5}, dataset_config=DataConfig(datasets_to_split=["train", "test"]), datasets={"train": ds, "test": ds}, ) # Test get config. assert isinstance(test.get_dataset_config(), DataConfig) test.fit() def test_split(ray_start_4_cpus): ds = ray.data.range(10) # Split all by default test = TestBasic( 2, True, {"train": 5, "test": 5, "val": 5}, datasets={"train": ds, "test": ds, "val": ds}, ) test.fit() # Test flag "all" test = TestBasic( 2, True, {"train": 5, "test": 5}, datasets={"train": ds, "test": ds}, dataset_config=DataConfig(datasets_to_split="all"), ) # Test split train only. test = TestBasic( 2, True, {"train": 5, "test": 10}, datasets={"train": ds, "test": ds}, dataset_config=DataConfig(datasets_to_split=["train"]), ) test.fit() # Test invalid arguments for datasets_to_split in ["train", ("train"), {}]: with pytest.raises(TypeError, match="`datasets_to_split` should be.*"): test = TestBasic( 2, True, {"train": 5, "test": 10}, datasets={"train": ds, "test": ds}, dataset_config=DataConfig(datasets_to_split=datasets_to_split), ) # Test empty `datasets_to_split` list test = TestBasic( 2, True, {"train": 10, "test": 10}, datasets={"train": ds, "test": ds}, dataset_config=DataConfig(datasets_to_split=[]), ) test.fit() def test_configure_execution_options_carryover_context(ray_start_4_cpus): """Tests that execution options in DataContext are carried over to DatConfig automatically.""" ctx = ray.data.DataContext.get_current() ctx.execution_options.preserve_order = True ctx.execution_options.verbose_progress = True data_config = DataConfig() ingest_options = data_config.default_ingest_options() assert ingest_options.preserve_order is True assert ingest_options.verbose_progress is True @pytest.mark.parametrize("enable_locality", [True, False]) def test_configure_locality(enable_locality): data_config = DataConfig(enable_shard_locality=enable_locality) mock_ds = MagicMock() mock_ds.streaming_split = MagicMock() mock_ds.copy = MagicMock(return_value=mock_ds) world_size = 2 worker_handles = [MagicMock() for _ in range(world_size)] worker_node_ids = ["node" + str(i) for i in range(world_size)] data_config.configure( datasets={"train": mock_ds}, world_size=world_size, worker_handles=worker_handles, worker_node_ids=worker_node_ids, ) mock_ds.streaming_split.assert_called_once() mock_ds.streaming_split.assert_called_with( world_size, equal=True, locality_hints=worker_node_ids if enable_locality else None, ) class CustomConfig(DataConfig): def __init__(self): pass def configure(self, *args, **kwargs): ds = ray.data.range(10) return [ {"train": ds.iterator()}, {"train": ds.iterator()}, ] def test_custom_config_subclass(ray_start_4_cpus): test = TestBasic( 1, True, {"train": 10}, dataset_config=CustomConfig(), ) test.fit() class TestRandom(DataParallelTrainer): def __init__(self, num_workers: int, expect_random: bool, **kwargs): def train_loop_per_worker(): data_shard = train.get_dataset_shard("train") assert isinstance(data_shard, DataIterator), data_shard epoch1 = list(data_shard.iter_rows()) epoch2 = list(data_shard.iter_rows()) print("Epochs", epoch1, "\n", epoch2) if expect_random: assert epoch1 != epoch2 else: assert epoch1 == epoch2 kwargs.pop("scaling_config", None) super().__init__( train_loop_per_worker=train_loop_per_worker, scaling_config=ScalingConfig(num_workers=num_workers), **kwargs, ) def test_per_epoch_preprocessing(ray_start_4_cpus): ds = ray.data.range(100, override_num_blocks=100).randomize_block_order() test = TestRandom(2, True, datasets={"train": ds}) test.fit() ds = ray.data.range(100, override_num_blocks=100).random_shuffle() test = TestRandom(2, True, datasets={"train": ds}) test.fit() ds = ray.data.range(100, override_num_blocks=100).map( lambda x: {"id": x["id"] * random.random()} ) test = TestRandom(2, True, datasets={"train": ds}) test.fit() def test_materialized_preprocessing(ray_start_4_cpus): # TODO(ekl) we should test all these configs with splitting enabled, but this # requires implementing deterministic streaming split. ds = ray.data.range(100, override_num_blocks=100).randomize_block_order() ds = ds.materialize() test = TestRandom( 2, False, datasets={"train": ds}, dataset_config=DataConfig(datasets_to_split=[]), ) test.fit() ds = ray.data.range(100, override_num_blocks=100).random_shuffle() ds = ds.materialize() test = TestRandom( 2, False, datasets={"train": ds}, dataset_config=DataConfig(datasets_to_split=[]), ) test.fit() ds = ray.data.range(100, override_num_blocks=100).map( lambda x: {"id": x["id"] * random.random()} ) ds = ds.materialize() test = TestRandom( 2, False, datasets={"train": ds}, dataset_config=DataConfig(datasets_to_split=[]), ) test.fit() def _run_data_config_resource_test(data_config): cluster_cpus, cluster_gpus = 20, 10 num_workers = 2 # Resources used by training workers. cpus_per_worker, gpus_per_worker = 2, 1 original_execution_options = data_config._get_execution_options("train") ray.init(num_cpus=cluster_cpus, num_gpus=cluster_gpus) class MyTrainer(DataParallelTrainer): def __init__(self, **kwargs): def train_loop_fn(): train_ds = train.get_dataset_shard("train") new_execution_options = train_ds.get_context().execution_options if original_execution_options.is_resource_limits_default(): # If the original resource limits are default, the new resource # limits should be the default as well. assert new_execution_options.is_resource_limits_default() exclude_resources = new_execution_options.exclude_resources assert ( exclude_resources.cpu == original_execution_options.exclude_resources.cpu + cpus_per_worker * num_workers + 1 # trainer coordinator ) assert ( exclude_resources.gpu == original_execution_options.exclude_resources.gpu + gpus_per_worker * num_workers ) else: # If the original resource limits are not default, the new resource # limits should be the same as the original ones. # And the new exclude_resources should be zero. assert ( new_execution_options.resource_limits == original_execution_options.resource_limits ) assert ( new_execution_options.exclude_resources == ExecutionResources.zero() ) kwargs.pop("scaling_config", None) super().__init__( train_loop_per_worker=train_loop_fn, scaling_config=ScalingConfig( num_workers=num_workers, use_gpu=True, resources_per_worker={ "CPU": cpus_per_worker, "GPU": gpus_per_worker, }, ), datasets={"train": ray.data.range(10)}, dataset_config=data_config, **kwargs, ) trainer = MyTrainer() trainer.fit() def test_data_config_default_resource_limits(shutdown_only): """Test that DataConfig preserves user-configured exclude_resources.""" execution_options = ExecutionOptions() execution_options.exclude_resources = execution_options.exclude_resources.copy( cpu=2, gpu=1 ) data_config = DataConfig(execution_options=execution_options) _run_data_config_resource_test(data_config) def test_data_config_manual_resource_limits(shutdown_only): """Test manually setting resource limits in DataConfig.""" execution_options = ExecutionOptions() execution_options.resource_limits = execution_options.resource_limits.copy( cpu=10, gpu=5 ) data_config = DataConfig(execution_options=execution_options) _run_data_config_resource_test(data_config) def test_v1_train_with_v2_data_autoscaler_sets_exclude_resources( shutdown_only, monkeypatch ): """Regression test for the Train V1 + V2 cluster autoscaler combination.""" monkeypatch.setenv("RAY_DATA_CLUSTER_AUTOSCALER", "V2") ray.init(num_cpus=10, num_gpus=2) num_train_cpus, num_train_gpus = 4.0, 2.0 data_config = DataConfig() data_config.set_train_total_resources( num_train_cpus=num_train_cpus, num_train_gpus=num_train_gpus ) iterators = data_config.configure( datasets={"train": ray.data.range(10)}, world_size=2, worker_handles=None, worker_node_ids=None, ) exclude_resources = ( iterators[0]["train"].get_context().execution_options.exclude_resources ) assert exclude_resources.cpu == num_train_cpus assert exclude_resources.gpu == num_train_gpus if __name__ == "__main__": import sys import pytest sys.exit(pytest.main(["-v", "-x", __file__]))