chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,143 @@
|
||||
import logging
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import simulate_s3_bucket
|
||||
from ray.cluster_utils import Cluster
|
||||
|
||||
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
|
||||
from ray.tests.conftest import (
|
||||
propagate_logs, # noqa
|
||||
pytest_runtest_makereport, # noqa
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_1_cpu_1_gpu():
|
||||
address_info = ray.init(num_cpus=1, num_gpus=1)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_2_gpus():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shutdown_only():
|
||||
yield None
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_gpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=4, num_gpus=2)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_neuron_cores():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=4, resources={"neuron_cores": 2})
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_4_node_4_cpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(4):
|
||||
cluster.add_node(num_cpus=4)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_4_gpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=2, num_gpus=4)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_cpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=2)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_bucket_uri():
|
||||
from ray.air._internal.uri_utils import URI
|
||||
|
||||
port = 5002
|
||||
region = "us-west-2"
|
||||
with simulate_s3_bucket(port=port, region=region) as s3_uri:
|
||||
s3 = boto3.client(
|
||||
"s3", region_name=region, endpoint_url=f"http://localhost:{port}"
|
||||
)
|
||||
# Bucket name will be autogenerated/unique per test
|
||||
bucket_name = URI(s3_uri).name
|
||||
s3.create_bucket(
|
||||
Bucket=bucket_name,
|
||||
CreateBucketConfiguration={"LocationConstraint": region},
|
||||
)
|
||||
# Disable server HTTP request logging
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
yield s3_uri
|
||||
logging.getLogger("werkzeug").setLevel(logging.INFO)
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
|
||||
|
||||
class DummyPreprocessor(Preprocessor):
|
||||
_is_fittable = False
|
||||
|
||||
def __init__(self, transform=None):
|
||||
self.id = uuid.uuid4()
|
||||
|
||||
if transform is None:
|
||||
self.transform = lambda b: b
|
||||
else:
|
||||
self.transform = transform
|
||||
|
||||
def transform_batch(self, batch):
|
||||
self._batch_transformed = True
|
||||
return self.transform(batch)
|
||||
|
||||
def _transform_pandas(self, df):
|
||||
return df
|
||||
|
||||
@property
|
||||
def has_preprocessed(self):
|
||||
return hasattr(self, "_batch_transformed")
|
||||
|
||||
def __eq__(self, other_preprocessor):
|
||||
return self.id == other_preprocessor.id
|
||||
@@ -0,0 +1,171 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.utils.data import DataLoader
|
||||
from torchmetrics import Accuracy
|
||||
|
||||
from ray import train
|
||||
from ray.train.lightning._lightning_utils import import_lightning
|
||||
|
||||
pl = import_lightning()
|
||||
|
||||
|
||||
class LinearModule(pl.LightningModule):
|
||||
def __init__(self, input_dim, output_dim, strategy="ddp", fail_epoch=-1) -> None:
|
||||
super().__init__()
|
||||
self.linear = nn.Linear(input_dim, output_dim)
|
||||
self.loss = []
|
||||
self.strategy = strategy
|
||||
self.restored = train.get_checkpoint() is not None
|
||||
self.fail_epoch = fail_epoch
|
||||
|
||||
def forward(self, input):
|
||||
if isinstance(input, dict) and len(input) == 1:
|
||||
input = list(input.values())[0]
|
||||
return self.linear(input)
|
||||
|
||||
def training_step(self, batch):
|
||||
if not self.restored and self.fail_epoch == self.current_epoch:
|
||||
raise RuntimeError
|
||||
|
||||
output = self.forward(batch)
|
||||
loss = torch.sum(output)
|
||||
self.log("loss", loss)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
loss = self.forward(val_batch)
|
||||
self.loss.append(loss)
|
||||
return {"val_loss": loss}
|
||||
|
||||
def test_step(self, batch, batch_idx):
|
||||
loss = self.forward(batch)
|
||||
return {"test_loss": loss}
|
||||
|
||||
def on_validation_epoch_end(self) -> None:
|
||||
avg_loss = torch.stack(self.loss).mean()
|
||||
self.log("val_loss", avg_loss)
|
||||
self.loss.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx):
|
||||
return self.forward(batch)
|
||||
|
||||
def configure_optimizers(self):
|
||||
if self.strategy == "fsdp":
|
||||
# Feed FSDP wrapped model parameters to optimizer
|
||||
return torch.optim.AdamW(self.trainer.model.parameters(), lr=0.1)
|
||||
else:
|
||||
return torch.optim.AdamW(self.parameters(), lr=0.1)
|
||||
|
||||
|
||||
class DoubleLinearModule(pl.LightningModule):
|
||||
def __init__(self, input_dim_1, input_dim_2, output_dim) -> None:
|
||||
super().__init__()
|
||||
self.linear_1 = nn.Linear(input_dim_1, output_dim)
|
||||
self.linear_2 = nn.Linear(input_dim_2, output_dim)
|
||||
self.loss = []
|
||||
|
||||
def forward(self, batch):
|
||||
input_1 = batch["input_1"]
|
||||
input_2 = batch["input_2"]
|
||||
return self.linear_1(input_1) + self.linear_2(input_2)
|
||||
|
||||
def training_step(self, batch):
|
||||
output = self.forward(batch)
|
||||
loss = torch.sum(output)
|
||||
self.log("loss", loss)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
loss = self.forward(val_batch)
|
||||
self.loss.append(loss)
|
||||
return {"val_loss": loss}
|
||||
|
||||
def on_validation_epoch_end(self) -> None:
|
||||
print("Validation Epoch:", self.current_epoch)
|
||||
avg_loss = torch.stack(self.loss).mean()
|
||||
self.log("val_loss", avg_loss)
|
||||
self.loss.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx):
|
||||
return self.forward(batch)
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.AdamW(self.parameters(), lr=0.1)
|
||||
|
||||
|
||||
class DummyDataModule(pl.LightningDataModule):
|
||||
def __init__(self, batch_size: int = 8, dataset_size: int = 256) -> None:
|
||||
super().__init__()
|
||||
self.batch_size = batch_size
|
||||
self.train_data = torch.randn(dataset_size, 32)
|
||||
self.val_data = torch.randn(dataset_size, 32)
|
||||
self.test_data = torch.randn(dataset_size, 32)
|
||||
|
||||
def train_dataloader(self):
|
||||
return DataLoader(self.train_data, batch_size=self.batch_size)
|
||||
|
||||
def val_dataloader(self):
|
||||
return DataLoader(self.val_data, batch_size=self.batch_size)
|
||||
|
||||
def test_dataloader(self):
|
||||
return DataLoader(self.test_data, batch_size=self.batch_size)
|
||||
|
||||
|
||||
class LightningMNISTClassifier(pl.LightningModule):
|
||||
def __init__(self, lr: float, layer_1: int, layer_2: int):
|
||||
super(LightningMNISTClassifier, self).__init__()
|
||||
|
||||
self.lr = lr
|
||||
# mnist images are (1, 28, 28) (channels, width, height)
|
||||
self.layer_1 = torch.nn.Linear(28 * 28, layer_1)
|
||||
self.layer_2 = torch.nn.Linear(layer_1, layer_2)
|
||||
self.layer_3 = torch.nn.Linear(layer_2, 10)
|
||||
self.accuracy = Accuracy(task="multiclass", num_classes=10, top_k=1)
|
||||
self.val_acc_list = []
|
||||
self.val_loss_list = []
|
||||
|
||||
def forward(self, x):
|
||||
batch_size, channels, width, height = x.size()
|
||||
x = x.view(batch_size, -1)
|
||||
x = self.layer_1(x)
|
||||
x = torch.relu(x)
|
||||
x = self.layer_2(x)
|
||||
x = torch.relu(x)
|
||||
x = self.layer_3(x)
|
||||
x = torch.log_softmax(x, dim=1)
|
||||
return x
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.Adam(self.parameters(), lr=self.lr)
|
||||
|
||||
def training_step(self, train_batch, batch_idx):
|
||||
x, y = train_batch
|
||||
logits = self.forward(x)
|
||||
loss = F.nll_loss(logits, y)
|
||||
acc = self.accuracy(logits, y)
|
||||
self.log("ptl/train_loss", loss)
|
||||
self.log("ptl/train_accuracy", acc)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
x, y = val_batch
|
||||
logits = self.forward(x)
|
||||
loss = F.nll_loss(logits, y)
|
||||
acc = self.accuracy(logits, y)
|
||||
self.val_acc_list.append(acc)
|
||||
self.val_loss_list.append(loss)
|
||||
return {"val_loss": loss, "val_accuracy": acc}
|
||||
|
||||
def on_validation_epoch_end(self):
|
||||
avg_loss = torch.stack(self.val_loss_list).mean()
|
||||
avg_acc = torch.stack(self.val_acc_list).mean()
|
||||
self.log("ptl/val_loss", avg_loss)
|
||||
self.log("ptl/val_accuracy", avg_acc)
|
||||
self.val_acc_list.clear()
|
||||
self.val_loss_list.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx, dataloader_idx=None):
|
||||
x = batch
|
||||
logits = self.forward(x)
|
||||
return torch.argmax(logits, dim=-1)
|
||||
@@ -0,0 +1,158 @@
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
import ray.train
|
||||
import ray.tune
|
||||
from ray.train.constants import ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.util.annotations import RayDeprecationWarning
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_v2_migration_deprecation_messages(monkeypatch):
|
||||
monkeypatch.setenv(ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR, "1")
|
||||
yield
|
||||
monkeypatch.delenv(ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR)
|
||||
|
||||
|
||||
def test_trainer_restore():
|
||||
with pytest.warns(RayDeprecationWarning, match="restore"):
|
||||
try:
|
||||
DataParallelTrainer.restore("dummy")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="can_restore"):
|
||||
try:
|
||||
DataParallelTrainer.can_restore("dummy")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_trainer_valid_configs(ray_start_4_cpus, tmp_path):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
run_config=ray.train.RunConfig(
|
||||
storage_path=tmp_path,
|
||||
failure_config=ray.train.FailureConfig(max_failures=1),
|
||||
),
|
||||
).fit()
|
||||
|
||||
for warning in w:
|
||||
assert not (
|
||||
warning.category == RayDeprecationWarning
|
||||
and "`RunConfig` class should be imported from `ray.tune`"
|
||||
in str(warning.message)
|
||||
)
|
||||
|
||||
|
||||
def test_trainer_deprecated_configs():
|
||||
with pytest.warns(RayDeprecationWarning, match="metadata"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
metadata={"dummy": "dummy"},
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="resume_from_checkpoint"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
resume_from_checkpoint=ray.train.Checkpoint.from_directory("dummy"),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="fail_fast"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
failure_config=ray.train.FailureConfig(fail_fast=True)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="trainer_resources"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(trainer_resources={"CPU": 1}),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="verbose"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(verbose=True),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="log_to_file"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(log_to_file=True),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="stop"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(stop={"training_iteration": 1}),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="callbacks"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(callbacks=[ray.tune.Callback()]),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="progress_reporter"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
progress_reporter=ray.tune.ProgressReporter()
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="sync_config"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
sync_config=ray.train.SyncConfig(sync_artifacts=True)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_train_context_deprecations(ray_start_4_cpus, tmp_path):
|
||||
def train_fn_per_worker(config):
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_dir"):
|
||||
ray.train.get_context().get_trial_dir()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_id"):
|
||||
ray.train.get_context().get_trial_id()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_name"):
|
||||
ray.train.get_context().get_trial_name()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_resources"):
|
||||
ray.train.get_context().get_trial_resources()
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_fn_per_worker,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
run_config=ray.train.RunConfig(storage_path=tmp_path),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_v2_enabled_error(monkeypatch):
|
||||
"""Running a V1 Trainer with V2 enabled should raise an error."""
|
||||
from ray.train.v2._internal.constants import V2_ENABLED_ENV_VAR
|
||||
|
||||
monkeypatch.setenv(V2_ENABLED_ENV_VAR, "1")
|
||||
|
||||
with pytest.raises(DeprecationWarning, match="Detected use of a deprecated"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,660 @@
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Set
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray._private.accelerators.neuron import NEURON_RT_VISIBLE_CORES_ENV_VAR
|
||||
from ray.air._internal.util import StartTraceback
|
||||
|
||||
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
|
||||
from ray.tests.conftest import pytest_runtest_makereport # noqa
|
||||
from ray.train import DataConfig
|
||||
from ray.train._internal.backend_executor import (
|
||||
BackendExecutor,
|
||||
InactiveWorkerGroupError,
|
||||
TrainBackendError,
|
||||
TrainingWorkerError,
|
||||
)
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train._internal.worker_group import WorkerGroup, WorkerMetadata
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.constants import (
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
|
||||
JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S,
|
||||
TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S,
|
||||
TRAIN_ENABLE_WORKER_SPREAD_ENV,
|
||||
)
|
||||
from ray.train.torch import TorchConfig
|
||||
from ray.train.v2.jax.config import JaxConfig
|
||||
from ray.util.placement_group import get_current_placement_group
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
from ray.util.state import list_actors
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _start_training(backend_executor: BackendExecutor, fn):
|
||||
storage = StorageContext(
|
||||
storage_path=tempfile.mkdtemp(),
|
||||
experiment_dir_name="exp_name",
|
||||
trial_dir_name="trial_name",
|
||||
)
|
||||
backend_executor.start_training(
|
||||
train_func=fn,
|
||||
datasets={},
|
||||
metadata={},
|
||||
data_config=DataConfig(),
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
|
||||
def gen_execute_special(special_f):
|
||||
def execute_async_special(self, f):
|
||||
"""Runs f on worker 0, special_f on other workers."""
|
||||
futures = [
|
||||
self.workers[0]
|
||||
.actor._RayTrainWorker__execute.options(name=f.__name__)
|
||||
.remote(f)
|
||||
]
|
||||
for worker in self.workers[1:]:
|
||||
futures.append(
|
||||
worker.actor._RayTrainWorker__execute.options(
|
||||
name=special_f.__name__
|
||||
).remote(special_f)
|
||||
)
|
||||
return futures
|
||||
|
||||
return execute_async_special
|
||||
|
||||
|
||||
class TestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return TestBackend
|
||||
|
||||
|
||||
class TestBackend(Backend):
|
||||
def on_start(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
def on_shutdown(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
|
||||
original_add_workers = WorkerGroup.add_workers
|
||||
|
||||
|
||||
def mock_add_workers(self, num_workers):
|
||||
original_add_workers(self, num_workers)
|
||||
for i, worker in enumerate(self.workers):
|
||||
metadata = WorkerMetadata(
|
||||
node_id=str(i % 2),
|
||||
node_ip=str(i % 2),
|
||||
hostname=0,
|
||||
resource_ids={"GPU": ["0"]},
|
||||
pid=0,
|
||||
)
|
||||
worker.metadata = metadata
|
||||
|
||||
|
||||
def mock_add_workers_to_nodes_with_same_ip(self, num_workers):
|
||||
original_add_workers(self, num_workers)
|
||||
for i, worker in enumerate(self.workers):
|
||||
metadata = WorkerMetadata(
|
||||
node_id=str(i % 2),
|
||||
node_ip=0,
|
||||
hostname=0,
|
||||
resource_ids={"GPU": ["0"]},
|
||||
pid=0,
|
||||
)
|
||||
worker.metadata = metadata
|
||||
|
||||
|
||||
def test_start(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
with pytest.raises(InactiveWorkerGroupError):
|
||||
_start_training(e, lambda: 1)
|
||||
e.start()
|
||||
assert len(e.worker_group) == 2
|
||||
|
||||
|
||||
def test_initialization_hook(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
|
||||
def init_hook():
|
||||
import os
|
||||
|
||||
os.environ["TEST"] = "1"
|
||||
|
||||
e.start(initialization_hook=init_hook)
|
||||
|
||||
def check():
|
||||
import os
|
||||
|
||||
return os.getenv("TEST", "0")
|
||||
|
||||
_start_training(e, check)
|
||||
assert e.finish_training() == ["1", "1"]
|
||||
|
||||
|
||||
def test_shutdown(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
assert len(e.worker_group) == 2
|
||||
e.shutdown()
|
||||
with pytest.raises(InactiveWorkerGroupError):
|
||||
_start_training(e, lambda: 1)
|
||||
|
||||
|
||||
def test_train(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
|
||||
def test_local_ranks(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert set(e.finish_training()) == {0, 1}
|
||||
|
||||
|
||||
def test_local_ranks_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=4)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 1, 0, 1]
|
||||
|
||||
|
||||
def test_local_world_size(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(WorkerGroup, "add_workers", mock_add_workers):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_world_size()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [2, 2, 1]
|
||||
|
||||
|
||||
def test_local_world_size_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(
|
||||
WorkerGroup, "add_workers", mock_add_workers_to_nodes_with_same_ip
|
||||
):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_world_size()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [2, 2, 1]
|
||||
|
||||
|
||||
def test_node_ranks(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(WorkerGroup, "add_workers", mock_add_workers):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_node_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 0, 1]
|
||||
|
||||
|
||||
def test_node_ranks_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(
|
||||
WorkerGroup, "add_workers", mock_add_workers_to_nodes_with_same_ip
|
||||
):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_node_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 0, 1]
|
||||
|
||||
|
||||
def test_train_failure(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.get_next_results()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.pause_reporting()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.finish_training()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
_start_training(e, lambda: 2)
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
|
||||
def test_single_worker_user_failure(ray_start_2_cpus):
|
||||
"""Tests if training fails immediately if one worker raises an Exception
|
||||
while executing the user training code."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def single_worker_user_failure():
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
raise RuntimeError
|
||||
else:
|
||||
time.sleep(1000000)
|
||||
|
||||
_start_training(e, single_worker_user_failure)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.get_next_results()
|
||||
assert isinstance(exc.value.__cause__, RuntimeError)
|
||||
|
||||
|
||||
def test_single_worker_actor_failure(ray_start_2_cpus):
|
||||
"""Tests is training fails immediately if one worker actor dies."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def single_worker_actor_failure():
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
# Simulate actor failure
|
||||
os._exit(1)
|
||||
else:
|
||||
time.sleep(1000)
|
||||
|
||||
_start_training(e, single_worker_actor_failure)
|
||||
|
||||
with pytest.raises(TrainingWorkerError):
|
||||
e.get_next_results()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not supported in python 3.12+"
|
||||
)
|
||||
def test_tensorflow_start(ray_start_2_cpus):
|
||||
from ray.train.tensorflow import TensorflowConfig
|
||||
|
||||
num_workers = 2
|
||||
tensorflow_config = TensorflowConfig()
|
||||
e = BackendExecutor(tensorflow_config, num_workers=num_workers)
|
||||
e.start()
|
||||
|
||||
def get_tf_config():
|
||||
import json
|
||||
import os
|
||||
|
||||
return json.loads(os.environ["TF_CONFIG"])
|
||||
|
||||
_start_training(e, get_tf_config)
|
||||
results = e.finish_training()
|
||||
assert len(results) == num_workers
|
||||
|
||||
workers = [result["cluster"]["worker"] for result in results]
|
||||
assert all(worker == workers[0] for worker in workers)
|
||||
|
||||
indexes = [result["task"]["index"] for result in results]
|
||||
assert len(set(indexes)) == num_workers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("init_method", ["env", "tcp"])
|
||||
def test_torch_start_shutdown(ray_start_2_cpus, init_method):
|
||||
torch_config = TorchConfig(backend="gloo", init_method=init_method)
|
||||
e = BackendExecutor(torch_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def check_process_group():
|
||||
import torch
|
||||
|
||||
return (
|
||||
torch.distributed.is_initialized()
|
||||
and torch.distributed.get_world_size() == 2
|
||||
)
|
||||
|
||||
_start_training(e, check_process_group)
|
||||
assert all(e.finish_training())
|
||||
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
_start_training(e, check_process_group)
|
||||
assert not any(e.finish_training())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_method, timeout_s", [("env", 5), ("tcp", 5), ("env", 0), ("tcp", 0)]
|
||||
)
|
||||
def test_torch_process_group_shutdown_timeout(
|
||||
ray_start_2_cpus, monkeypatch, init_method, timeout_s
|
||||
):
|
||||
monkeypatch.setenv(TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S, timeout_s)
|
||||
torch_config = TorchConfig(backend="gloo", init_method=init_method)
|
||||
e = BackendExecutor(torch_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
# Verify that we do not raise an exception even if we time out
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0, 1]] * 2),
|
||||
(3, [[0]] + [[0, 1]] * 2),
|
||||
(4, [[0, 1]] * 4),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices(ray_2_node_2_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 1}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(
|
||||
2,
|
||||
[[0]] * 2,
|
||||
),
|
||||
(3, [[0, 1]] * 3),
|
||||
(4, [[0, 1]] * 4),
|
||||
(5, [[0]] + [[0, 1]] * 4),
|
||||
(6, [[0]] * 2 + [[0, 1]] * 4),
|
||||
(7, [[0, 1]] * 7),
|
||||
(8, [[0, 1]] * 8),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices_fractional(ray_2_node_2_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
if worker_results[0] != len(worker_results[1]):
|
||||
raise ValueError(
|
||||
"Invalid test parameter. Length of expected result should "
|
||||
"match number of workers."
|
||||
)
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 0.5}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0, 1]]),
|
||||
(2, [[0, 1, 2, 3]] * 2),
|
||||
(3, [[0, 1]] + [[0, 1, 2, 3]] * 2),
|
||||
(4, [[0, 1, 2, 3]] * 4),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices_multiple(ray_2_node_4_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
if worker_results[0] != len(worker_results[1]):
|
||||
raise ValueError(
|
||||
"Invalid test parameter. Length of expected result should "
|
||||
"match number of workers."
|
||||
)
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 2}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0, 1]] * 2),
|
||||
(3, [[0]] + [[0, 1]] * 2),
|
||||
(4, [[0, 1]] * 4),
|
||||
],
|
||||
)
|
||||
def test_neuron_core_accelerator_ids(ray_2_node_2_neuron_cores, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
neuron_resource_ids = os.environ[NEURON_RT_VISIBLE_CORES_ENV_VAR]
|
||||
# Sort the runtime ids to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(neuron_resource_ids.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
# sharing enabled by default
|
||||
os.environ.pop(ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV, None)
|
||||
e = BackendExecutor(
|
||||
config,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker={"neuron_cores": 1},
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0]] + [[1]]),
|
||||
(3, [[0]] * 2 + [[1]]),
|
||||
(4, [[0]] * 2 + [[1]] * 2),
|
||||
],
|
||||
)
|
||||
def test_neuron_core_accelerator_ids_sharing_disabled(
|
||||
ray_2_node_2_neuron_cores, worker_results
|
||||
):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
neuron_resource_ids = os.environ[NEURON_RT_VISIBLE_CORES_ENV_VAR]
|
||||
# Sort the runtime ids to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(neuron_resource_ids.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV] = "0"
|
||||
e = BackendExecutor(
|
||||
config,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker={"neuron_cores": 1},
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
def get_node_id_set() -> Set[str]:
|
||||
return {a.node_id for a in list_actors()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [3, 4, 5])
|
||||
def test_placement_group_pack(ray_4_node_4_cpu, num_workers):
|
||||
"""Tests that workers are packed on nodes."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=num_workers)
|
||||
e.start()
|
||||
node_id_set = get_node_id_set()
|
||||
assert len(node_id_set) == math.ceil(num_workers / 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [3, 4, 5])
|
||||
def test_placement_group_spread(ray_4_node_4_cpu, num_workers):
|
||||
"""Tests that workers are spread across nodes."""
|
||||
os.environ[TRAIN_ENABLE_WORKER_SPREAD_ENV] = "1"
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=num_workers)
|
||||
e.start()
|
||||
node_id_set = get_node_id_set()
|
||||
assert len(node_id_set) == min(num_workers, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("placement_group_capture_child_tasks", [True, False])
|
||||
def test_placement_group_parent(ray_4_node_4_cpu, placement_group_capture_child_tasks):
|
||||
"""Tests that parent placement group will be used."""
|
||||
num_workers = 2
|
||||
bundle = {"CPU": 1}
|
||||
bundles = [bundle.copy() for _ in range(num_workers + 1)]
|
||||
placement_group = ray.util.placement_group(bundles)
|
||||
|
||||
def train_func():
|
||||
return get_current_placement_group().id
|
||||
|
||||
@ray.remote
|
||||
def test():
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
_start_training(e, train_func)
|
||||
return e.finish_training()
|
||||
|
||||
results_future = test.options(
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||||
placement_group=placement_group,
|
||||
placement_group_capture_child_tasks=placement_group_capture_child_tasks,
|
||||
),
|
||||
).remote()
|
||||
results = ray.get(results_future)
|
||||
for worker_result in results:
|
||||
if placement_group_capture_child_tasks:
|
||||
assert worker_result == placement_group.id
|
||||
else:
|
||||
assert worker_result != placement_group.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout_s", [5, 0])
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12),
|
||||
reason="Current jax version is not supported in python 3.12+",
|
||||
)
|
||||
def test_jax_distributed_shutdown_timeout(ray_start_2_cpus, monkeypatch, timeout_s):
|
||||
"""Test that JAX distributed shutdown respects the timeout env var."""
|
||||
monkeypatch.setenv(JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S, str(timeout_s))
|
||||
jax_config = JaxConfig(use_tpu=True)
|
||||
e = BackendExecutor(jax_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
# Verify that we do not raise an exception even if we time out
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,197 @@
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray.data.context import DataContext
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train._internal.session import get_session
|
||||
from ray.train.base_trainer import format_datasets_for_repr
|
||||
from ray.train.trainer import BaseTrainer
|
||||
from ray.util.placement_group import get_current_placement_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummyTrainer(BaseTrainer):
|
||||
_scaling_config_allowed_keys = BaseTrainer._scaling_config_allowed_keys + [
|
||||
"num_workers",
|
||||
"use_gpu",
|
||||
"resources_per_worker",
|
||||
"placement_strategy",
|
||||
]
|
||||
|
||||
def __init__(self, train_loop, custom_arg=None, **kwargs):
|
||||
self.custom_arg = custom_arg
|
||||
self.train_loop = train_loop
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def training_loop(self) -> None:
|
||||
self.train_loop(self)
|
||||
|
||||
|
||||
def test_trainer_fit(ray_start_4_cpus):
|
||||
def training_loop(self):
|
||||
train.report(dict(my_metric=1))
|
||||
|
||||
trainer = DummyTrainer(train_loop=training_loop)
|
||||
result = trainer.fit()
|
||||
assert result.metrics["my_metric"] == 1
|
||||
|
||||
|
||||
def test_validate_datasets(ray_start_4_cpus):
|
||||
with pytest.raises(ValueError) as e:
|
||||
DummyTrainer(train_loop=None, datasets=1)
|
||||
assert "`datasets` should be a dict mapping" in str(e.value)
|
||||
|
||||
with pytest.raises(ValueError) as e:
|
||||
DummyTrainer(train_loop=None, datasets={"train": 1})
|
||||
assert "The Dataset under train key is not a `ray.data.Dataset`"
|
||||
|
||||
|
||||
def test_resources(ray_start_4_cpus):
|
||||
def check_cpus(self):
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
|
||||
assert ray.available_resources()["CPU"] == 4
|
||||
trainer = DummyTrainer(
|
||||
check_cpus,
|
||||
scaling_config=ScalingConfig(
|
||||
trainer_resources={"CPU": 2}, resources_per_worker={}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_arg_override(ray_start_4_cpus):
|
||||
def check_override(self):
|
||||
assert self.scaling_config.num_workers == 1
|
||||
# Should do deep update.
|
||||
assert not self.custom_arg["outer"]["inner"]
|
||||
assert self.custom_arg["outer"]["fixed"] == 1
|
||||
|
||||
pg = get_current_placement_group()
|
||||
assert len(pg.bundle_specs) == 1 # Merged trainer and worker bundle
|
||||
|
||||
scale_config = ScalingConfig(num_workers=4)
|
||||
trainer = DummyTrainer(
|
||||
check_override,
|
||||
custom_arg={"outer": {"inner": True, "fixed": 1}},
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
|
||||
new_config = {
|
||||
"custom_arg": {"outer": {"inner": False}},
|
||||
"scaling_config": ScalingConfig(num_workers=1),
|
||||
}
|
||||
|
||||
tune.run(trainer.as_trainable(), config=new_config)
|
||||
|
||||
|
||||
def test_reserved_cpu_warnings_no_cpu_usage(ray_start_1_cpu_1_gpu):
|
||||
"""Ensure there is no divide by zero error if trial requires no CPUs."""
|
||||
|
||||
def train_loop(config):
|
||||
pass
|
||||
|
||||
trainer = DummyTrainer(
|
||||
train_loop,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=1, use_gpu=True, trainer_resources={"CPU": 0}
|
||||
),
|
||||
datasets={"train": ray.data.range(10)},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_setup(ray_start_4_cpus):
|
||||
def check_setup(self):
|
||||
assert self._has_setup
|
||||
|
||||
class DummyTrainerWithSetup(DummyTrainer):
|
||||
def setup(self):
|
||||
self._has_setup = True
|
||||
|
||||
trainer = DummyTrainerWithSetup(check_setup)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_repr(ray_start_4_cpus):
|
||||
def training_loop(self):
|
||||
pass
|
||||
|
||||
trainer = DummyTrainer(
|
||||
training_loop,
|
||||
datasets={
|
||||
"train": ray.data.from_items([1, 2, 3]),
|
||||
},
|
||||
)
|
||||
|
||||
representation = repr(trainer)
|
||||
|
||||
assert "DummyTrainer" in representation
|
||||
|
||||
|
||||
def test_metadata_propagation(ray_start_4_cpus):
|
||||
class MyTrainer(BaseTrainer):
|
||||
def training_loop(self):
|
||||
assert get_session().metadata == {"a": 1, "b": 1}
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
checkpoint = Checkpoint.from_directory(path)
|
||||
checkpoint.set_metadata({"b": 2, "c": 3})
|
||||
train.report(dict(my_metric=1), checkpoint=checkpoint)
|
||||
|
||||
trainer = MyTrainer(metadata={"a": 1, "b": 1})
|
||||
result = trainer.fit()
|
||||
meta_out = result.checkpoint.get_metadata()
|
||||
assert meta_out == {"a": 1, "b": 2, "c": 3}, meta_out
|
||||
|
||||
|
||||
def test_data_context_propagation(ray_start_4_cpus):
|
||||
ctx = DataContext.get_current()
|
||||
# Fake DataContext attribute to propagate to worker.
|
||||
ctx.foo = "bar"
|
||||
|
||||
def training_loop(self):
|
||||
# Dummy train loop that checks that changes in the driver's
|
||||
# DataContext are propagated to the worker.
|
||||
ctx_worker = DataContext.get_current()
|
||||
assert ctx_worker.foo == "bar"
|
||||
|
||||
trainer = DummyTrainer(
|
||||
train_loop=training_loop,
|
||||
datasets={"train": ray.data.range(10)},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_large_params(ray_start_4_cpus):
|
||||
"""Tests that large params are not serialized with the trainer actor
|
||||
and are instead put into the object store separately."""
|
||||
huge_array = np.zeros(shape=int(1e8))
|
||||
|
||||
def training_loop(self):
|
||||
_ = huge_array
|
||||
|
||||
trainer = DummyTrainer(training_loop)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_format_datasets_for_repr(ray_start_4_cpus):
|
||||
datasets = {"train": ray.data.range(1), "test": ray.data.range(1)}
|
||||
|
||||
actual_repr = format_datasets_for_repr(datasets)
|
||||
|
||||
assert actual_repr == (
|
||||
"{'train': Dataset(num_rows=1, schema={id: int64}), "
|
||||
"'test': Dataset(num_rows=1, schema={id: int64})}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(sys.argv[1:] + ["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for BaseWorkerGroup implementation and usage."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train._internal.worker_group import WorkerGroup as V1WorkerGroup
|
||||
from ray.train.v2._internal.execution.worker_group.worker_group import (
|
||||
WorkerGroup as V2WorkerGroup,
|
||||
)
|
||||
|
||||
|
||||
def test_interface_abstract_methods():
|
||||
"""Test that BaseWorkerGroup enforces its abstract methods."""
|
||||
# Should not be able to instantiate interface directly
|
||||
with pytest.raises(TypeError):
|
||||
BaseWorkerGroup()
|
||||
|
||||
# Should not be able to create incomplete implementation
|
||||
class IncompleteWorkerGroup(BaseWorkerGroup):
|
||||
def execute(self, func, *args, **kwargs):
|
||||
pass
|
||||
|
||||
# Missing other abstract methods
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteWorkerGroup()
|
||||
|
||||
|
||||
def test_real_implementations_inherit_interface():
|
||||
"""Smoke test that real WorkerGroup implementations inherit from interface."""
|
||||
# Test inheritance
|
||||
assert issubclass(V1WorkerGroup, BaseWorkerGroup)
|
||||
assert issubclass(V2WorkerGroup, BaseWorkerGroup)
|
||||
|
||||
# Test that all abstract methods are implemented
|
||||
# If any abstract methods are missing, __abstractmethods__ will be non-empty
|
||||
assert (
|
||||
len(V1WorkerGroup.__abstractmethods__) == 0
|
||||
), f"V1 WorkerGroup missing abstract methods: {V1WorkerGroup.__abstractmethods__}"
|
||||
assert (
|
||||
len(V2WorkerGroup.__abstractmethods__) == 0
|
||||
), f"V2 WorkerGroup missing abstract methods: {V2WorkerGroup.__abstractmethods__}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,232 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.train._checkpoint import (
|
||||
_CHECKPOINT_TEMP_DIR_PREFIX,
|
||||
_METADATA_FILE_NAME,
|
||||
Checkpoint,
|
||||
_get_del_lock_path,
|
||||
_list_existing_del_locks,
|
||||
)
|
||||
from ray.train._internal.storage import _exists_at_fs_path, _upload_to_fs_path
|
||||
from ray.train.tests.test_new_persistence import _create_mock_custom_fs
|
||||
|
||||
_CHECKPOINT_CONTENT_FILE = "dummy.txt"
|
||||
|
||||
|
||||
@pytest.fixture(params=["local", "mock", "custom_fs"])
|
||||
def checkpoint(request, tmp_path):
|
||||
"""Fixture that sets up a checkpoint on different filesystems."""
|
||||
checkpoint_fs_type = request.param
|
||||
|
||||
checkpoint_path = tmp_path / "ckpt_dir"
|
||||
checkpoint_path.mkdir(exist_ok=True)
|
||||
(checkpoint_path / _CHECKPOINT_CONTENT_FILE).write_text("dummy")
|
||||
|
||||
if checkpoint_fs_type == "local":
|
||||
yield Checkpoint.from_directory(str(checkpoint_path))
|
||||
elif checkpoint_fs_type == "mock":
|
||||
_checkpoint = Checkpoint(path="mock:///mock_bucket/ckpt_dir")
|
||||
_upload_to_fs_path(
|
||||
local_path=str(checkpoint_path),
|
||||
fs=_checkpoint.filesystem,
|
||||
fs_path=_checkpoint.path,
|
||||
)
|
||||
# The "mock://" URI doesn't persist across different instances of
|
||||
# the pyarrow.fs.MockFileSystem, so we must make sure to return
|
||||
# the checkpoint with the same filesystem instance that we uploaded
|
||||
# some mock content to.
|
||||
yield _checkpoint
|
||||
elif checkpoint_fs_type == "custom_fs":
|
||||
custom_storage_fs = _create_mock_custom_fs(tmp_path / "custom_fs")
|
||||
_upload_to_fs_path(
|
||||
local_path=str(checkpoint_path),
|
||||
fs=custom_storage_fs,
|
||||
fs_path="mock_bucket/ckpt_dir",
|
||||
)
|
||||
yield Checkpoint(path="mock_bucket/ckpt_dir", filesystem=custom_storage_fs)
|
||||
|
||||
|
||||
def test_to_directory(checkpoint: Checkpoint):
|
||||
checkpoint_path = Path(checkpoint.to_directory())
|
||||
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert _CHECKPOINT_TEMP_DIR_PREFIX in checkpoint_path.name
|
||||
|
||||
|
||||
def test_to_directory_with_user_specified_path(checkpoint: Checkpoint, tmp_path):
|
||||
# Test with a string
|
||||
checkpoint_path = Path(checkpoint.to_directory(str(tmp_path / "special_dir")))
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert checkpoint_path.name == "special_dir"
|
||||
|
||||
# Test with a PathLike
|
||||
checkpoint_path = Path(checkpoint.to_directory(tmp_path / "special_dir"))
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert checkpoint_path.name == "special_dir"
|
||||
|
||||
|
||||
def test_multiprocess_to_directory(checkpoint: Checkpoint):
|
||||
"""Test the case where multiple processes are trying to checkpoint.
|
||||
|
||||
Only one process should download the checkpoint, and the others should
|
||||
wait until it's finished. In the end, a single checkpoint dir should be
|
||||
shared by all processes.
|
||||
"""
|
||||
if checkpoint.filesystem.type_name == "mock":
|
||||
pytest.skip("Mock filesystem cannot be pickled for use with Ray.")
|
||||
|
||||
@ray.remote
|
||||
def download_checkpoint(checkpoint: Checkpoint) -> str:
|
||||
return checkpoint.to_directory()
|
||||
|
||||
paths = [ray.get(download_checkpoint.remote(checkpoint)) for _ in range(5)]
|
||||
# Check that all the paths are the same (no duplicates).
|
||||
assert len(set(paths)) == 1
|
||||
|
||||
|
||||
def test_as_directory(checkpoint: Checkpoint):
|
||||
with checkpoint.as_directory() as checkpoint_path:
|
||||
checkpoint_path = Path(checkpoint_path)
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
# We should have directly returned the local path
|
||||
assert str(checkpoint_path) == checkpoint.path
|
||||
else:
|
||||
# We should have downloaded to a temp dir.
|
||||
assert _CHECKPOINT_TEMP_DIR_PREFIX in checkpoint_path.name
|
||||
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
# Checkpoint should not be deleted, if we directly gave the local path.
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
else:
|
||||
# Should have been deleted, if the checkpoint downloaded to a temp dir.
|
||||
assert not checkpoint_path.exists()
|
||||
|
||||
|
||||
def test_multiprocess_as_directory(checkpoint: Checkpoint, monkeypatch):
|
||||
"""Tests that deletion lock files are created and deleted correctly,
|
||||
when multiple processes are accessing a checkpoint with `as_directory`"""
|
||||
# If this is pointing to a local path, no lockfiles will be created.
|
||||
is_local_checkpoint = isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem)
|
||||
|
||||
monkeypatch.setattr(os, "getpid", lambda: 11111)
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir_1:
|
||||
lock_file_1 = Path(_get_del_lock_path(checkpoint_dir_1))
|
||||
|
||||
# Pretend that the 2nd `as_directory` is called from a different process.
|
||||
monkeypatch.setattr(os, "getpid", lambda: 22222)
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir_2:
|
||||
lock_file_2 = Path(_get_del_lock_path(checkpoint_dir_2))
|
||||
|
||||
# Should point both processes to the same canonical temp directory.
|
||||
assert checkpoint_dir_1 == checkpoint_dir_2
|
||||
|
||||
if is_local_checkpoint:
|
||||
# Check that 2 different lock files were created.
|
||||
assert not lock_file_1.exists()
|
||||
assert not lock_file_2.exists()
|
||||
else:
|
||||
# Check that 2 different lock files were created.
|
||||
assert lock_file_1.exists()
|
||||
assert lock_file_2.exists()
|
||||
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 2
|
||||
|
||||
if not is_local_checkpoint:
|
||||
# Check that the 2nd lock file was deleted.
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 1
|
||||
assert not lock_file_2.exists()
|
||||
|
||||
if not is_local_checkpoint:
|
||||
# Check that the 1st lock file was deleted.
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 0
|
||||
assert not lock_file_1.exists()
|
||||
|
||||
# Check that the temp checkpoint directory was deleted.
|
||||
assert not Path(checkpoint_dir_1).exists()
|
||||
|
||||
|
||||
def test_as_directory_lock_cleanup(checkpoint: Checkpoint):
|
||||
"""Errors when accessing a checkpoint with `as_directory`
|
||||
shouldn't leave behind lock files.
|
||||
"""
|
||||
with pytest.raises(RuntimeError):
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
raise RuntimeError
|
||||
|
||||
assert not _list_existing_del_locks(checkpoint_dir)
|
||||
|
||||
is_local_checkpoint = isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem)
|
||||
if not is_local_checkpoint:
|
||||
assert not Path(checkpoint_dir).exists()
|
||||
|
||||
|
||||
def test_as_directory_download_error(checkpoint: Checkpoint, monkeypatch):
|
||||
"""Errors during a checkpoint download should be raised directly when accessing
|
||||
it with the `as_directory` context manager."""
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
pytest.skip(
|
||||
"Local filesystem checkpoints don't download to a temp dir, so "
|
||||
"there's no error handling to test."
|
||||
)
|
||||
|
||||
error_text = "original error"
|
||||
|
||||
def to_directory_error(*args, **kwargs):
|
||||
raise RuntimeError(error_text)
|
||||
|
||||
monkeypatch.setattr(checkpoint, "to_directory", to_directory_error)
|
||||
|
||||
with pytest.raises(RuntimeError, match=error_text):
|
||||
with checkpoint.as_directory() as _:
|
||||
pass
|
||||
|
||||
|
||||
def test_metadata(checkpoint: Checkpoint):
|
||||
assert checkpoint.get_metadata() == {}
|
||||
|
||||
# No metadata file by default.
|
||||
assert not _exists_at_fs_path(
|
||||
fs=checkpoint.filesystem,
|
||||
fs_path=str(Path(checkpoint.path) / _METADATA_FILE_NAME),
|
||||
)
|
||||
|
||||
checkpoint.update_metadata({"foo": "bar"})
|
||||
assert checkpoint.get_metadata() == {"foo": "bar"}
|
||||
|
||||
checkpoint.update_metadata({"foo": "baz"})
|
||||
assert checkpoint.get_metadata() == {"foo": "baz"}
|
||||
|
||||
checkpoint.update_metadata({"x": 1})
|
||||
assert checkpoint.get_metadata() == {"foo": "baz", "x": 1}
|
||||
|
||||
# Set metadata completely resets the metadata.
|
||||
checkpoint.set_metadata({"y": [1, 2, 3]})
|
||||
assert checkpoint.get_metadata() == {"y": [1, 2, 3]}
|
||||
|
||||
# There should be a metadata file in the checkpoint directory.
|
||||
assert _exists_at_fs_path(
|
||||
fs=checkpoint.filesystem,
|
||||
fs_path=str(Path(checkpoint.path) / _METADATA_FILE_NAME),
|
||||
)
|
||||
|
||||
# Non JSON serializable metadata should raise an error.
|
||||
class Test:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
checkpoint.set_metadata({"non_json_serializable": Test()})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,239 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train import Checkpoint, CheckpointConfig
|
||||
from ray.train._internal.checkpoint_manager import _CheckpointManager, _TrainingResult
|
||||
from ray.train.constants import TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkpoint_paths(tmp_path):
|
||||
checkpoint_paths = []
|
||||
for i in range(10):
|
||||
checkpoint_path = tmp_path / f"ckpt_{i}"
|
||||
checkpoint_path.mkdir()
|
||||
(checkpoint_path / "dummy.txt").write_text(f"{i}")
|
||||
checkpoint_paths.append(checkpoint_path)
|
||||
|
||||
yield [str(path) for path in checkpoint_paths]
|
||||
|
||||
|
||||
def test_unlimited_checkpoints(checkpoint_paths: List[str]):
|
||||
manager = _CheckpointManager(checkpoint_config=CheckpointConfig(num_to_keep=None))
|
||||
|
||||
for i in range(10):
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 10
|
||||
|
||||
|
||||
def test_limited_checkpoints(checkpoint_paths: List[str]):
|
||||
manager = _CheckpointManager(checkpoint_config=CheckpointConfig(num_to_keep=2))
|
||||
|
||||
for i in range(10):
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 2
|
||||
|
||||
# Keep the latest checkpoints if no metric is given.
|
||||
assert {
|
||||
tracked_checkpoint.metrics["iter"]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
} == {8, 9}
|
||||
|
||||
# The first 8 checkpoints should be deleted.
|
||||
for i in range(8):
|
||||
assert not Path(checkpoint_paths[i]).exists()
|
||||
|
||||
assert Path(checkpoint_paths[8]).exists()
|
||||
assert Path(checkpoint_paths[9]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", ["min", "max"])
|
||||
def test_keep_checkpoints_by_score(order, checkpoint_paths):
|
||||
num_to_keep = 2
|
||||
score_attribute = "score"
|
||||
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=num_to_keep,
|
||||
checkpoint_score_attribute=score_attribute,
|
||||
checkpoint_score_order=order,
|
||||
)
|
||||
)
|
||||
|
||||
scores = []
|
||||
for i in range(10):
|
||||
score = random.random()
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i, score_attribute: score},
|
||||
)
|
||||
)
|
||||
scores.append(score)
|
||||
|
||||
sorted_scores = sorted(scores, reverse=order == "max")
|
||||
assert set(sorted_scores[:num_to_keep]) == {
|
||||
tracked_checkpoint.metrics[score_attribute]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
}
|
||||
|
||||
# Make sure the bottom checkpoints are deleted.
|
||||
best_checkpoint_iters = {
|
||||
tracked_checkpoint.metrics["iter"]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
}
|
||||
for i, checkpoint_path in enumerate(checkpoint_paths):
|
||||
if i in best_checkpoint_iters or i == 9:
|
||||
# The checkpoint should only exist if it's one of the top K or the latest.
|
||||
assert Path(checkpoint_path).exists()
|
||||
else:
|
||||
assert not Path(checkpoint_path).exists()
|
||||
|
||||
|
||||
def test_keep_latest_checkpoint(checkpoint_paths):
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=2,
|
||||
checkpoint_score_attribute="score",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
)
|
||||
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[0]),
|
||||
metrics={"score": 3.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[1]),
|
||||
metrics={"score": 2.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[2]),
|
||||
metrics={"score": 1.0},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 2
|
||||
|
||||
# The latest checkpoint with the lowest score should not be deleted yet.
|
||||
assert manager.latest_checkpoint_result.metrics["score"] == 1.0
|
||||
|
||||
# The latest checkpoint with the lowest score should not be deleted yet.
|
||||
assert Path(checkpoint_paths[2]).exists()
|
||||
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[3]),
|
||||
metrics={"score": 0.0},
|
||||
)
|
||||
)
|
||||
# A newer checkpoint came in. Even though the new one has a lower score, there are
|
||||
# already num_to_keep better checkpoints, so the previous one should be deleted.
|
||||
assert not Path(checkpoint_paths[2]).exists()
|
||||
|
||||
# Quick sanity check to make sure that the new checkpoint is kept.
|
||||
assert manager.latest_checkpoint_result.metrics["score"] == 0.0
|
||||
assert Path(checkpoint_paths[3]).exists()
|
||||
|
||||
# The original 2 checkpoints should still exist
|
||||
assert Path(checkpoint_paths[0]).exists()
|
||||
assert Path(checkpoint_paths[1]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metrics",
|
||||
[
|
||||
{"nested": {"sub": {"attr": 5}}},
|
||||
{"nested": {"sub/attr": 5}},
|
||||
{"nested/sub": {"attr": 5}},
|
||||
{"nested/sub/attr": 5},
|
||||
],
|
||||
)
|
||||
def test_nested_get_checkpoint_score(metrics):
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=2,
|
||||
checkpoint_score_attribute="nested/sub/attr",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
)
|
||||
|
||||
tracked_checkpoint = _TrainingResult(checkpoint=None, metrics=metrics)
|
||||
assert manager._get_checkpoint_score(tracked_checkpoint) == (True, 5.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_score_attr", [True, False])
|
||||
def test_only_store_score_attr(has_score_attr, checkpoint_paths, monkeypatch):
|
||||
monkeypatch.setenv(TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE, "1")
|
||||
|
||||
# Set up CheckpointManager.
|
||||
if has_score_attr:
|
||||
checkpoint_config = CheckpointConfig(
|
||||
num_to_keep=None,
|
||||
checkpoint_score_attribute="score",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
else:
|
||||
checkpoint_config = CheckpointConfig(num_to_keep=None)
|
||||
manager = _CheckpointManager(checkpoint_config=checkpoint_config)
|
||||
|
||||
# Ensure we insert TrainingResults with score in the right order.
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[0]),
|
||||
metrics={"score": 3.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[1]),
|
||||
metrics={"score": 1.0, "another_unsaved_metric": 6.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[2]),
|
||||
metrics={"another_unsaved_metric": 1.0},
|
||||
)
|
||||
)
|
||||
assert len(manager.best_checkpoint_results) == 3
|
||||
if has_score_attr:
|
||||
assert manager.best_checkpoint_results[0].metrics == {"score": 1.0}
|
||||
assert manager.best_checkpoint_results[0].checkpoint.path == checkpoint_paths[1]
|
||||
assert manager.best_checkpoint_results[1].metrics == {"score": 3.0}
|
||||
assert manager.best_checkpoint_results[1].checkpoint.path == checkpoint_paths[0]
|
||||
assert manager.best_checkpoint_results[2].metrics == {}
|
||||
assert manager.best_checkpoint_results[2].checkpoint.path == checkpoint_paths[2]
|
||||
else:
|
||||
assert manager.best_checkpoint_results[0].metrics == {}
|
||||
assert manager.best_checkpoint_results[0].checkpoint.path == checkpoint_paths[0]
|
||||
assert manager.best_checkpoint_results[1].metrics == {}
|
||||
assert manager.best_checkpoint_results[1].checkpoint.path == checkpoint_paths[1]
|
||||
assert manager.best_checkpoint_results[2].metrics == {}
|
||||
assert manager.best_checkpoint_results[2].checkpoint.path == checkpoint_paths[2]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,372 @@
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray._common.utils import RESOURCE_CONSTRAINT_PREFIX
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train._internal.backend_executor import BackendExecutor
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.utils import _in_ray_train_worker
|
||||
from ray.tune.callback import Callback
|
||||
from ray.tune.tune_config import TuneConfig
|
||||
from ray.tune.tuner import Tuner
|
||||
from ray.util.accelerators import NVIDIA_A100
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_4_gpus_4_extra():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=4, resources={"extra": 4})
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_4_gpus_4_a100():
|
||||
address_info = ray.init(
|
||||
num_cpus=4,
|
||||
num_gpus=4,
|
||||
resources={f"{RESOURCE_CONSTRAINT_PREFIX}{NVIDIA_A100}": 4},
|
||||
)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def gen_execute_single_async_special(special_f):
|
||||
def execute_single_async_special(self, i, f, *args, **kwargs):
|
||||
assert len(self.workers) == 2
|
||||
if i == 0 and hasattr(self, "should_fail") and self.should_fail:
|
||||
kwargs["train_func"] = special_f
|
||||
return (
|
||||
self.workers[i]
|
||||
.actor._RayTrainWorker__execute.options(name=f.__name__)
|
||||
.remote(f, *args, **kwargs)
|
||||
)
|
||||
|
||||
return execute_single_async_special
|
||||
|
||||
|
||||
def gen_new_backend_executor(special_f):
|
||||
"""Returns a BackendExecutor that runs special_f on worker 0 once."""
|
||||
|
||||
class TestBackendExecutor(BackendExecutor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._has_failed = False
|
||||
|
||||
def start_training(self, *args, **kwargs):
|
||||
special_execute = gen_execute_single_async_special(special_f)
|
||||
if not self._has_failed:
|
||||
self.worker_group.should_fail = True
|
||||
self._has_failed = True
|
||||
else:
|
||||
self.worker_group.should_fail = False
|
||||
with patch.object(WorkerGroup, "execute_single_async", special_execute):
|
||||
super().start_training(*args, **kwargs)
|
||||
|
||||
return TestBackendExecutor
|
||||
|
||||
|
||||
class CaptureReportCallback(Callback):
|
||||
def __init__(self):
|
||||
self.result_list = []
|
||||
|
||||
def on_trial_result(self, iteration, trials, trial, result, **info):
|
||||
self.result_list.append(result)
|
||||
|
||||
|
||||
scale_config = ScalingConfig(num_workers=2)
|
||||
|
||||
|
||||
def test_fit_train(ray_start_4_cpus):
|
||||
def train_func():
|
||||
train.report({"loss": 1})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=scale_config
|
||||
)
|
||||
assert trainer.fit().metrics["loss"] == 1
|
||||
|
||||
|
||||
def test_scaling_config(ray_start_4_cpus):
|
||||
def train_func():
|
||||
assert ray.available_resources()["CPU"] == 1
|
||||
train.report({"loss": 1})
|
||||
|
||||
assert ray.available_resources()["CPU"] == 4
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=ScalingConfig(num_workers=2)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_fit_train_config(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
train.report({"loss": config["x"]})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scale_config,
|
||||
train_loop_config={"x": 100},
|
||||
)
|
||||
assert trainer.fit().metrics["loss"] == 100
|
||||
|
||||
|
||||
def test_datasets(ray_start_4_cpus):
|
||||
num_train_data = 10
|
||||
num_val_data = 6
|
||||
|
||||
train_dataset = ray.data.range(num_train_data)
|
||||
val_dataset = ray.data.range(num_val_data)
|
||||
|
||||
def get_dataset():
|
||||
# Train dataset should be sharded.
|
||||
train_dataset = train.get_dataset_shard("train")
|
||||
train_ds_count = len(list(train_dataset.iter_rows()))
|
||||
assert train_ds_count == num_train_data / scale_config.num_workers
|
||||
# All other datasets should not be sharded.
|
||||
val_dataset = train.get_dataset_shard("val")
|
||||
val_ds_count = len(list(val_dataset.iter_rows()))
|
||||
assert val_ds_count == num_val_data / scale_config.num_workers
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=get_dataset,
|
||||
scaling_config=scale_config,
|
||||
datasets={"train": train_dataset, "val": val_dataset},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_invalid_train_loop():
|
||||
def train_loop(config, extra_arg):
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DataParallelTrainer(train_loop_per_worker=train_loop)
|
||||
|
||||
|
||||
def test_bad_return_in_train_loop(ray_start_4_cpus):
|
||||
"""Test to check if returns from train loop are discarded."""
|
||||
|
||||
# Simulates what happens with eg. torch models
|
||||
class FailOnUnpickle:
|
||||
def __reduce__(self):
|
||||
raise RuntimeError("Failing")
|
||||
|
||||
def train_loop(config):
|
||||
train.report({"loss": 1})
|
||||
return FailOnUnpickle()
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_loop, scaling_config=scale_config
|
||||
)
|
||||
|
||||
# No exception should happen here
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_tune(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
train.report({"loss": config["x"]})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config={"x": 100},
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={"train_loop_config": {"x": tune.choice([200, 300])}},
|
||||
tune_config=TuneConfig(num_samples=2),
|
||||
)
|
||||
result_grid = tuner.fit()
|
||||
assert result_grid[0].metrics["loss"] in [200, 300]
|
||||
|
||||
# Make sure original Trainer is not affected.
|
||||
assert trainer._train_loop_config["x"] == 100
|
||||
|
||||
|
||||
def test_fast_slow(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report(dict(index=i), checkpoint=checkpoint)
|
||||
|
||||
def train_slow():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report(dict(index=i), checkpoint=checkpoint)
|
||||
time.sleep(5)
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_slow)
|
||||
callback = CaptureReportCallback()
|
||||
|
||||
class DataParallelTrainerPatched(DataParallelTrainer):
|
||||
_backend_executor_cls = new_backend_executor_cls
|
||||
|
||||
trainer = DataParallelTrainerPatched(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
run_config=RunConfig(callbacks=[callback]),
|
||||
)
|
||||
results = trainer.fit()
|
||||
|
||||
assert load_dict_checkpoint(results.checkpoint)["epoch"] == 1
|
||||
|
||||
result_list = callback.result_list
|
||||
assert len(result_list) == 2
|
||||
|
||||
|
||||
def test_mismatch_report(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for _ in range(2):
|
||||
train.report(dict(loss=1))
|
||||
|
||||
def train_mismatch():
|
||||
train.report(dict(loss=1))
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_mismatch)
|
||||
|
||||
class DataParallelTrainerPatched(DataParallelTrainer):
|
||||
_backend_executor_cls = new_backend_executor_cls
|
||||
|
||||
trainer = DataParallelTrainerPatched(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_world_rank(ray_start_4_cpus, tmp_path):
|
||||
def train_func():
|
||||
world_rank = train.get_context().get_world_rank()
|
||||
(tmp_path / f"{world_rank}").touch()
|
||||
train.report(dict(world_rank=world_rank))
|
||||
|
||||
trainer = DataParallelTrainer(train_func, scaling_config=scale_config)
|
||||
trainer.fit()
|
||||
|
||||
created_files = list(tmp_path.glob("*"))
|
||||
assert len(created_files) == 2
|
||||
assert {int(file.name) for file in created_files} == {0, 1}
|
||||
|
||||
|
||||
def test_gpu_requests(ray_start_4_cpus_4_gpus_4_extra, tmp_path):
|
||||
def get_visible_devices_for_workers():
|
||||
return [file.read_text() for file in tmp_path.glob("*")]
|
||||
|
||||
class CudaTestBackend(Backend):
|
||||
share_cuda_visible_devices = True
|
||||
|
||||
class CudaTestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return CudaTestBackend
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
||||
world_rank = train.get_context().get_world_rank()
|
||||
(tmp_path / f"{world_rank}").write_text(cuda_visible_devices)
|
||||
train.report(dict(devices=cuda_visible_devices))
|
||||
|
||||
# 0 GPUs will be requested and should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=False),
|
||||
)
|
||||
trainer.fit()
|
||||
assert get_visible_devices_for_workers() == ["", ""]
|
||||
|
||||
# 1 GPU will be requested and should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=True),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
visible_devices = [",".join(sorted(r.split(","))) for r in visible_devices]
|
||||
assert visible_devices == ["0,1", "0,1"]
|
||||
|
||||
# Partial GPUs should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2, use_gpu=True, resources_per_worker={"GPU": 0.1}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
assert visible_devices == ["0", "0"]
|
||||
|
||||
# Multiple GPUs should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2, use_gpu=True, resources_per_worker={"GPU": 2}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
visible_devices = [",".join(sorted(r.split(","))) for r in visible_devices]
|
||||
assert visible_devices == ["0,1,2,3", "0,1,2,3"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accelerator_type", [NVIDIA_A100, None])
|
||||
def test_config_accelerator_type(ray_start_4_cpus_4_gpus_4_a100, accelerator_type):
|
||||
def train_func():
|
||||
# Ensure all workers are scheduled on nodes with specified accelerators
|
||||
assigned_resources = ray.get_runtime_context().get_assigned_resources()
|
||||
if accelerator_type:
|
||||
accelerator_key = f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"
|
||||
assert accelerator_key in assigned_resources
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=4,
|
||||
use_gpu=True,
|
||||
accelerator_type=accelerator_type,
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_in_ray_train_worker(ray_start_4_cpus):
|
||||
assert not _in_ray_train_worker()
|
||||
|
||||
def train_fn():
|
||||
assert _in_ray_train_worker()
|
||||
|
||||
trainer = DataParallelTrainer(train_fn)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import CheckpointConfig, RunConfig, ScalingConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
scale_config = ScalingConfig(num_workers=2)
|
||||
NUM_EPOCHS = 3
|
||||
|
||||
|
||||
def checkpoint_train_func():
|
||||
for i in range(NUM_EPOCHS):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report({"epoch": i}, checkpoint=checkpoint)
|
||||
|
||||
|
||||
def test_checkpoint(ray_start_4_cpus):
|
||||
"""Test that a checkpoint is created and accessible."""
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=checkpoint_train_func,
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert load_dict_checkpoint(result.checkpoint)["epoch"] == NUM_EPOCHS - 1
|
||||
|
||||
|
||||
def test_resume_from_checkpoint(ray_start_4_cpus, tmpdir):
|
||||
"""Test that training can be resumed from a reported checkpoint."""
|
||||
|
||||
def train_func():
|
||||
checkpoint = train.get_checkpoint()
|
||||
if checkpoint:
|
||||
epoch = load_dict_checkpoint(checkpoint)["epoch"]
|
||||
else:
|
||||
epoch = 0
|
||||
for i in range(epoch, epoch + 2):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report({"epoch": i}, checkpoint=checkpoint)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=scale_config
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert load_dict_checkpoint(result.checkpoint)["epoch"] == 1
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scale_config,
|
||||
resume_from_checkpoint=result.checkpoint,
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert load_dict_checkpoint(result.checkpoint)["epoch"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["min", "max"])
|
||||
def test_checkpoints_to_keep(ray_start_4_cpus, mode):
|
||||
"""
|
||||
Test that ``CheckpointConfig`` is respected.
|
||||
|
||||
- Report 4 times with different metrics.
|
||||
- Assert that the kept checkpoints match the expectation.
|
||||
"""
|
||||
|
||||
def train_func():
|
||||
with create_dict_checkpoint({"idx": 0}) as checkpoint:
|
||||
train.report(dict(loss=float("nan")), checkpoint=checkpoint) # nan, deleted
|
||||
with create_dict_checkpoint({"idx": 1}) as checkpoint:
|
||||
train.report(
|
||||
dict(loss=3), checkpoint=checkpoint
|
||||
) # best for min, worst for max (del)
|
||||
with create_dict_checkpoint({"idx": 2}) as checkpoint:
|
||||
train.report(
|
||||
dict(loss=7), checkpoint=checkpoint
|
||||
) # worst for min (del), best for max
|
||||
with create_dict_checkpoint({"idx": 3}) as checkpoint:
|
||||
train.report(dict(loss=5), checkpoint=checkpoint)
|
||||
|
||||
checkpoint_config = CheckpointConfig(
|
||||
num_to_keep=2, checkpoint_score_attribute="loss", checkpoint_score_order=mode
|
||||
)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
run_config=RunConfig(checkpoint_config=checkpoint_config),
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert len(result.best_checkpoints) == 2
|
||||
|
||||
# Last checkpoint
|
||||
assert load_dict_checkpoint(result.checkpoint)["idx"] == 3
|
||||
|
||||
if mode == "min":
|
||||
indices = [3, 1]
|
||||
losses = [5, 3]
|
||||
else:
|
||||
indices = [3, 2]
|
||||
losses = [5, 7]
|
||||
|
||||
assert load_dict_checkpoint(result.best_checkpoints[0][0])["idx"] == indices[0]
|
||||
assert load_dict_checkpoint(result.best_checkpoints[1][0])["idx"] == indices[1]
|
||||
assert result.best_checkpoints[0][1]["loss"] == losses[0]
|
||||
assert result.best_checkpoints[1][1]["loss"] == losses[1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
If a user uses Trainer API directly with wandb integration, they expect to see
|
||||
* train_loop_config to show up in wandb.config.
|
||||
|
||||
This test uses mocked call into wandb API.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air.integrations.wandb import WANDB_ENV_VAR
|
||||
from ray.air.tests.mocked_wandb_integration import WandbTestExperimentLogger
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import (
|
||||
train_func as linear_train_func,
|
||||
)
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
CONFIG = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": 3}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_train_loop_config", (True, False))
|
||||
def test_trainer_wandb_integration(
|
||||
ray_start_4_cpus, with_train_loop_config, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv(WANDB_ENV_VAR, "9012")
|
||||
|
||||
def train_func(config=None):
|
||||
config = config or CONFIG
|
||||
result = linear_train_func(config)
|
||||
assert len(result) == config["epochs"]
|
||||
assert result[-1]["loss"] < result[0]["loss"]
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
if with_train_loop_config:
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=CONFIG,
|
||||
scaling_config=scaling_config,
|
||||
run_config=RunConfig(callbacks=[logger]),
|
||||
)
|
||||
else:
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
run_config=RunConfig(callbacks=[logger]),
|
||||
)
|
||||
trainer.fit()
|
||||
config = list(logger.trial_logging_actor_states.values())[0].config
|
||||
|
||||
if with_train_loop_config:
|
||||
assert "train_loop_config" in config
|
||||
else:
|
||||
assert "train_loop_config" not in config
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,98 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_fashion_mnist_example import (
|
||||
train_func_per_worker as fashion_mnist_train_func,
|
||||
)
|
||||
from ray.train.examples.pytorch.torch_linear_example import (
|
||||
train_func as linear_train_func,
|
||||
)
|
||||
from ray.train.examples.pytorch.torch_quick_start import (
|
||||
train_func as torch_quick_start_train_func,
|
||||
)
|
||||
from ray.train.examples.tf.tensorflow_quick_start import (
|
||||
train_func as tf_quick_start_train_func,
|
||||
)
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 2])
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not supported in python 3.12+"
|
||||
)
|
||||
def test_tensorflow_mnist(ray_start_4_cpus, num_workers):
|
||||
from ray.train.examples.tf.tensorflow_mnist_example import (
|
||||
train_func as tensorflow_mnist_train_func,
|
||||
)
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
num_workers = num_workers
|
||||
epochs = 3
|
||||
|
||||
config = {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
trainer = TensorflowTrainer(
|
||||
tensorflow_mnist_train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not supported in python 3.12+"
|
||||
)
|
||||
def test_tf_non_distributed(ray_start_4_cpus):
|
||||
"""Make sure Ray Train works without TF MultiWorkerMirroredStrategy."""
|
||||
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
trainer = TensorflowTrainer(
|
||||
tf_quick_start_train_func, scaling_config=ScalingConfig(num_workers=1)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 2])
|
||||
def test_torch_linear(ray_start_4_cpus, num_workers):
|
||||
num_workers = num_workers
|
||||
epochs = 3
|
||||
|
||||
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
|
||||
trainer = TorchTrainer(
|
||||
linear_train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_torch_fashion_mnist(ray_start_4_cpus):
|
||||
num_workers = 2
|
||||
epochs = 3
|
||||
|
||||
config = {"lr": 1e-3, "batch_size_per_worker": 32, "epochs": epochs}
|
||||
trainer = TorchTrainer(
|
||||
fashion_mnist_train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_torch_non_distributed(ray_start_4_cpus):
|
||||
"""Make sure Ray Train works without torch DDP."""
|
||||
|
||||
trainer = TorchTrainer(
|
||||
torch_quick_start_train_func, scaling_config=ScalingConfig(num_workers=1)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,345 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Union
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torchvision
|
||||
from torch.nn.parallel import DistributedDataParallel
|
||||
from torch.utils.data import DataLoader, DistributedSampler
|
||||
|
||||
import ray
|
||||
import ray.data
|
||||
from ray import train
|
||||
from ray.exceptions import RayTaskError
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import LinearDataset
|
||||
from ray.train.torch.config import TorchConfig
|
||||
from ray.train.torch.torch_trainer import TorchTrainer
|
||||
from ray.train.trainer import TrainingFailedError
|
||||
|
||||
|
||||
class LinearDatasetDict(LinearDataset):
|
||||
"""Modifies the LinearDataset to return a Dict instead of a Tuple."""
|
||||
|
||||
def __getitem__(self, index):
|
||||
return {"x": self.x[index, None], "y": self.y[index, None]}
|
||||
|
||||
|
||||
class NonTensorDataset(LinearDataset):
|
||||
"""Modifies the LinearDataset to also return non-tensor objects."""
|
||||
|
||||
def __getitem__(self, index):
|
||||
return {"x": self.x[index, None], "y": 2}
|
||||
|
||||
|
||||
def write_rank_data(tmp_path: Path, data: Union[int, List, Dict]):
|
||||
rank = train.get_context().get_world_rank()
|
||||
with open(tmp_path / f"{rank}.json", "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def get_data_from_all_ranks(tmp_path: Path) -> Dict[int, Union[int, List, Dict]]:
|
||||
rank_data = {}
|
||||
for rank_file in tmp_path.glob("*.json"):
|
||||
rank = int(rank_file.stem)
|
||||
with open(rank_file, "r") as f:
|
||||
data = json.load(f)
|
||||
rank_data[rank] = data
|
||||
return rank_data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cuda_visible_devices", ["", "1,2"])
|
||||
@pytest.mark.parametrize("num_gpus_per_worker", [0.5, 1, 2])
|
||||
def test_torch_get_device(
|
||||
shutdown_only, num_gpus_per_worker, cuda_visible_devices, monkeypatch, tmp_path
|
||||
):
|
||||
if cuda_visible_devices:
|
||||
# Test if `get_device` is correct even with user specified env var.
|
||||
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", cuda_visible_devices)
|
||||
|
||||
ray.init(num_cpus=4, num_gpus=2)
|
||||
|
||||
def train_fn():
|
||||
# Confirm that the TorchConfig Prologue is effective
|
||||
assert torch.cuda.current_device() == train.torch.get_device().index
|
||||
# Make sure environment variable is being set correctly.
|
||||
if cuda_visible_devices:
|
||||
visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
assert visible_devices == "1,2"
|
||||
|
||||
devices = sorted([device.index for device in train.torch.get_devices()])
|
||||
write_rank_data(tmp_path, devices)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=int(2 / num_gpus_per_worker),
|
||||
use_gpu=True,
|
||||
resources_per_worker={"GPU": num_gpus_per_worker},
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
rank_data = get_data_from_all_ranks(tmp_path)
|
||||
devices = list(rank_data.values())
|
||||
|
||||
if num_gpus_per_worker == 0.5:
|
||||
assert sorted(devices) == [[0], [0], [1], [1]]
|
||||
elif num_gpus_per_worker == 1:
|
||||
assert sorted(devices) == [[0], [1]]
|
||||
elif num_gpus_per_worker == 2:
|
||||
assert sorted(devices[0]) == [0, 1]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"New parameter for this test has been added without checking that the "
|
||||
"correct devices have been returned."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_gpus_per_worker", [0.5, 1, 2])
|
||||
def test_torch_get_device_dist(ray_2_node_2_gpu, num_gpus_per_worker, tmp_path):
|
||||
@patch("torch.cuda.is_available", lambda: True)
|
||||
def train_fn():
|
||||
# Confirm that the TorchConfig Prologue is effective
|
||||
assert torch.cuda.current_device() == train.torch.get_device().index
|
||||
devices = sorted([device.index for device in train.torch.get_devices()])
|
||||
write_rank_data(tmp_path, devices)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
# use gloo instead of nccl, since nccl is not supported
|
||||
# on this virtual gpu ray environment
|
||||
torch_config=TorchConfig(backend="gloo"),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=int(4 / num_gpus_per_worker),
|
||||
use_gpu=True,
|
||||
resources_per_worker={"GPU": num_gpus_per_worker},
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
rank_data = get_data_from_all_ranks(tmp_path)
|
||||
devices = list(rank_data.values())
|
||||
|
||||
# cluster setups: 2 nodes, 2 gpus per node
|
||||
# `CUDA_VISIBLE_DEVICES` is set to "0,1" on node 1 and node 2
|
||||
if num_gpus_per_worker == 0.5:
|
||||
# worker gpu topology:
|
||||
# 4 workers on node 1, 4 workers on node 2
|
||||
# `ray.get_gpu_ids()` returns [0], [0], [1], [1] on node 1
|
||||
# and [0], [0], [1], [1] on node 2
|
||||
assert sorted(devices) == [[0], [0], [0], [0], [1], [1], [1], [1]]
|
||||
elif num_gpus_per_worker == 1:
|
||||
# worker gpu topology:
|
||||
# 2 workers on node 1, 2 workers on node 2
|
||||
# `ray.get_gpu_ids()` returns [0], [1] on node 1 and [0], [1] on node 2
|
||||
assert sorted(devices) == [[0], [0], [1], [1]]
|
||||
elif num_gpus_per_worker == 2:
|
||||
# worker gpu topology:
|
||||
# 1 workers on node 1, 1 workers on node 2
|
||||
# `ray.get_gpu_ids()` returns {0, 1} on node 1 and {0, 1} on node 2
|
||||
# and `device_id` returns the one index from each set.
|
||||
# So total count of devices should be 2.
|
||||
assert devices == [[0, 1], [0, 1]]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"New parameter for this test has been added without checking that the "
|
||||
"correct devices have been returned."
|
||||
)
|
||||
|
||||
|
||||
def test_torch_prepare_model(ray_start_4_cpus_2_gpus):
|
||||
"""Tests if ``prepare_model`` correctly wraps in DDP."""
|
||||
|
||||
def train_fn():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
|
||||
# Wrap in DDP.
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
# Make sure model is wrapped in DDP.
|
||||
assert isinstance(model, DistributedDataParallel)
|
||||
|
||||
# Make sure model is on cuda.
|
||||
assert next(model.parameters()).is_cuda
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
def train_fn_manual_override():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
|
||||
# Wrap in DDP and manually specify CPU.
|
||||
model = train.torch.prepare_model(model, device=torch.device("cpu"))
|
||||
|
||||
# Make sure model is wrapped in DDP.
|
||||
assert isinstance(model, DistributedDataParallel)
|
||||
|
||||
# Make sure model is NOT on cuda since we manually specified CPU.
|
||||
assert not next(model.parameters()).is_cuda
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_torch_prepare_model_uses_device(ray_start_4_cpus_2_gpus):
|
||||
"""Tests if `prepare_model` uses the train.torch.get_device even if it does not
|
||||
match with the local rank."""
|
||||
# The below test should pass without errors.
|
||||
|
||||
@patch.object(
|
||||
ray.train.torch.train_loop_utils,
|
||||
"get_device",
|
||||
lambda: torch.device(f"cuda:{1 - train.get_context().get_local_rank()}"),
|
||||
)
|
||||
def train_func():
|
||||
# These assert statements must hold for prepare_model to wrap with DDP.
|
||||
assert torch.cuda.is_available()
|
||||
assert train.get_context().get_world_size() > 1
|
||||
model = torch.nn.Linear(1, 1)
|
||||
data = torch.ones(1)
|
||||
data = data.to(train.torch.get_device())
|
||||
model = train.torch.prepare_model(model)
|
||||
model(data)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dataset", (LinearDataset, LinearDatasetDict, NonTensorDataset)
|
||||
)
|
||||
def test_torch_prepare_dataloader(ray_start_4_cpus_2_gpus, dataset):
|
||||
data_loader = DataLoader(dataset(a=1, b=2, size=10))
|
||||
|
||||
def train_fn():
|
||||
wrapped_data_loader = train.torch.prepare_data_loader(data_loader)
|
||||
|
||||
# Check that DistributedSampler has been added to the data loader.
|
||||
assert isinstance(wrapped_data_loader.sampler, DistributedSampler)
|
||||
|
||||
# Make sure you can properly iterate through the DataLoader.
|
||||
# Case where the dataset returns a tuple or list from __getitem__.
|
||||
if isinstance(dataset, LinearDataset):
|
||||
for batch in wrapped_data_loader:
|
||||
x = batch[0]
|
||||
y = batch[1]
|
||||
|
||||
# Make sure the data is on the correct device.
|
||||
assert x.is_cuda and y.is_cuda
|
||||
# Case where the dataset returns a dict from __getitem__.
|
||||
elif isinstance(dataset, LinearDatasetDict):
|
||||
for batch in wrapped_data_loader:
|
||||
for x, y in zip(batch["x"], batch["y"]):
|
||||
# Make sure the data is on the correct device.
|
||||
assert x.is_cuda and y.is_cuda
|
||||
|
||||
elif isinstance(dataset, NonTensorDataset):
|
||||
for batch in wrapped_data_loader:
|
||||
for x, y in zip(batch["x"], batch["y"]):
|
||||
# Make sure the data is on the correct device.
|
||||
assert x.is_cuda and y == 2
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data_loader_num_workers", (0, 2))
|
||||
def test_enable_reproducibility(ray_start_4_cpus_2_gpus, data_loader_num_workers):
|
||||
# NOTE: Reproducible results aren't guaranteed between seeded executions, even with
|
||||
# identical hardware and software dependencies. This test should be okay given that
|
||||
# it only runs for two epochs on a small dataset.
|
||||
# NOTE: I've chosen to use a ResNet model over a more simple model, because
|
||||
# `enable_reproducibility` disables CUDA convolution benchmarking, and a simpler
|
||||
# model (e.g., linear) might not test this feature.
|
||||
def train_func():
|
||||
train.torch.enable_reproducibility()
|
||||
|
||||
model = torchvision.models.resnet18()
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
dataset_length = 128
|
||||
dataset = torch.utils.data.TensorDataset(
|
||||
torch.randn(dataset_length, 3, 32, 32),
|
||||
torch.randint(low=0, high=1000, size=(dataset_length,)),
|
||||
)
|
||||
|
||||
# num_workers > 0 tests for https://github.com/ray-project/ray/issues/30247
|
||||
dataloader = torch.utils.data.DataLoader(
|
||||
dataset, batch_size=64, num_workers=data_loader_num_workers
|
||||
)
|
||||
dataloader = train.torch.prepare_data_loader(dataloader)
|
||||
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
|
||||
|
||||
model.train()
|
||||
for epoch in range(2):
|
||||
for images, targets in dataloader:
|
||||
optimizer.zero_grad()
|
||||
|
||||
outputs = model(images)
|
||||
loss = torch.nn.functional.cross_entropy(outputs, targets)
|
||||
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
train.report(dict(loss=loss.item()))
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
result1 = trainer.fit()
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
result2 = trainer.fit()
|
||||
|
||||
assert result1.metrics["loss"] == result2.metrics["loss"]
|
||||
|
||||
|
||||
def test_torch_fail_on_nccl_timeout(ray_start_4_cpus_2_gpus):
|
||||
"""Tests that TorchTrainer raises exception on NCCL timeouts."""
|
||||
|
||||
def train_fn():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
# Rank 0 worker will never reach the collective operation.
|
||||
# NCCL should timeout.
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
while True:
|
||||
time.sleep(100)
|
||||
|
||||
torch.distributed.barrier()
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=True),
|
||||
torch_config=TorchConfig(timeout_s=5),
|
||||
)
|
||||
|
||||
# Training should fail and not hang.
|
||||
with pytest.raises(TrainingFailedError) as exc_info:
|
||||
trainer.fit()
|
||||
assert isinstance(exc_info.value.__cause__, RayTaskError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", "-s", __file__]))
|
||||
@@ -0,0 +1,71 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import ray
|
||||
import ray.data
|
||||
import ray.train as train
|
||||
from ray import tune
|
||||
from ray.air.config import ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import LinearDataset
|
||||
from ray.train.torch.torch_trainer import TorchTrainer
|
||||
|
||||
|
||||
class LinearDatasetDict(LinearDataset):
|
||||
"""Modifies the LinearDataset to return a Dict instead of a Tuple."""
|
||||
|
||||
def __getitem__(self, index):
|
||||
return {"x": self.x[index, None], "y": self.y[index, None]}
|
||||
|
||||
|
||||
class NonTensorDataset(LinearDataset):
|
||||
"""Modifies the LinearDataset to also return non-tensor objects."""
|
||||
|
||||
def __getitem__(self, index):
|
||||
return {"x": self.x[index, None], "y": 2}
|
||||
|
||||
|
||||
# Currently in DataParallelTrainers we only report metrics from rank 0.
|
||||
# For testing purposes here, we need to be able to report from all
|
||||
# workers.
|
||||
class TorchTrainerPatchedMultipleReturns(TorchTrainer):
|
||||
def _report(self, training_iterator) -> None:
|
||||
for results in training_iterator:
|
||||
tune.report(results=results)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_gpu", (True, False))
|
||||
def test_torch_iter_torch_batches_auto_device(ray_start_4_cpus_2_gpus, use_gpu):
|
||||
"""
|
||||
Tests that iter_torch_batches in TorchTrainer worker function uses the
|
||||
default device.
|
||||
"""
|
||||
|
||||
def train_fn():
|
||||
dataset = train.get_dataset_shard("train")
|
||||
for batch in dataset.iter_torch_batches(dtypes=torch.float, device="cpu"):
|
||||
assert str(batch["data"].device) == "cpu"
|
||||
|
||||
# Autodetect
|
||||
for batch in dataset.iter_torch_batches(dtypes=torch.float):
|
||||
assert str(batch["data"].device) == str(train.torch.get_device())
|
||||
|
||||
dataset = ray.data.from_numpy(np.array([[1, 2, 3, 4, 5], [1, 2, 3, 4, 5]]).T)
|
||||
# Test that this works outside a Train function
|
||||
for batch in dataset.iter_torch_batches(dtypes=torch.float, device="cpu"):
|
||||
assert str(batch["data"].device) == "cpu"
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=use_gpu),
|
||||
datasets={"train": dataset},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", "-s", __file__]))
|
||||
@@ -0,0 +1,62 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from ray.train.torch.train_loop_utils import _WrappedDataLoader
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_choice", "auto_transfer"),
|
||||
[
|
||||
("cpu", True),
|
||||
("cpu", False),
|
||||
("cuda", True),
|
||||
("cuda", False),
|
||||
],
|
||||
)
|
||||
def test_auto_transfer_data_from_host_to_device(
|
||||
ray_start_1_cpu_1_gpu, device_choice, auto_transfer
|
||||
):
|
||||
def compute_average_runtime(func):
|
||||
device = torch.device(device_choice)
|
||||
start = torch.cuda.Event(enable_timing=True)
|
||||
end = torch.cuda.Event(enable_timing=True)
|
||||
runtime = []
|
||||
for _ in range(10):
|
||||
torch.cuda.synchronize()
|
||||
start.record()
|
||||
func(device)
|
||||
end.record()
|
||||
torch.cuda.synchronize()
|
||||
runtime.append(start.elapsed_time(end))
|
||||
return np.mean(runtime)
|
||||
|
||||
small_dataloader = [
|
||||
(torch.randn((1024 * 4, 1024 * 4), device="cpu"),) for _ in range(10)
|
||||
]
|
||||
|
||||
def host_to_device(device):
|
||||
for (x,) in small_dataloader:
|
||||
x = x.to(device)
|
||||
torch.matmul(x, x)
|
||||
|
||||
def host_to_device_auto_pipeline(device):
|
||||
wrapped_dataloader = _WrappedDataLoader(small_dataloader, device, auto_transfer)
|
||||
for (x,) in wrapped_dataloader:
|
||||
torch.matmul(x, x)
|
||||
|
||||
# test if all four configurations are okay
|
||||
with_auto_transfer = compute_average_runtime(host_to_device_auto_pipeline)
|
||||
|
||||
if device_choice == "cuda" and auto_transfer:
|
||||
# check if auto transfer is faster than manual transfer
|
||||
without_auto_transfer = compute_average_runtime(host_to_device)
|
||||
assert with_auto_transfer <= without_auto_transfer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", "-s", __file__]))
|
||||
@@ -0,0 +1,56 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_fashion_mnist_example import (
|
||||
train_func_per_worker as fashion_mnist_train_func,
|
||||
)
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12),
|
||||
reason="Tensorflow is not installed in CI for Python 3.12",
|
||||
)
|
||||
def test_tensorflow_mnist_gpu(ray_start_4_cpus_2_gpus):
|
||||
from ray.train.examples.tf.tensorflow_mnist_example import (
|
||||
train_func as tensorflow_mnist_train_func,
|
||||
)
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
num_workers = 2
|
||||
epochs = 3
|
||||
|
||||
config = {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
trainer = TensorflowTrainer(
|
||||
tensorflow_mnist_train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=True),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_torch_fashion_mnist_gpu(ray_start_4_cpus_2_gpus):
|
||||
num_workers = 2
|
||||
epochs = 3
|
||||
|
||||
config = {"lr": 1e-3, "batch_size_per_worker": 32, "epochs": epochs}
|
||||
trainer = TorchTrainer(
|
||||
fashion_mnist_train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=True),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_train_linear_dataset_gpu(ray_start_4_cpus_2_gpus):
|
||||
from ray.train.examples.pytorch.torch_regression_example import train_regression
|
||||
|
||||
train_regression(num_workers=2, use_gpu=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", "-s", __file__]))
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets
|
||||
from torchvision.transforms import transforms
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.examples.horovod.horovod_pytorch_example import (
|
||||
Net,
|
||||
train_func as hvd_train_func,
|
||||
)
|
||||
from ray.train.horovod import HorovodTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def run_image_prediction(model: torch.nn.Module, images: torch.Tensor) -> torch.Tensor:
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
return torch.exp(model(images)).argmax(dim=1)
|
||||
|
||||
|
||||
def test_horovod(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
result = hvd_train_func(config)
|
||||
assert len(result) == epochs
|
||||
assert result[-1] < result[0]
|
||||
|
||||
num_workers = 1
|
||||
epochs = 10
|
||||
scaling_config = ScalingConfig(num_workers=num_workers)
|
||||
config = {"num_epochs": epochs, "save_model_as_dict": False}
|
||||
trainer = HorovodTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
model = Net()
|
||||
with result.checkpoint.as_directory() as checkpoint_dir:
|
||||
model.load_state_dict(torch.load(os.path.join(checkpoint_dir, "model.pt")))
|
||||
|
||||
# Find some test data to run on.
|
||||
test_set = datasets.MNIST(
|
||||
"./data",
|
||||
train=False,
|
||||
download=True,
|
||||
transform=transforms.Compose(
|
||||
[transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
|
||||
),
|
||||
)
|
||||
|
||||
test_dataloader = DataLoader(test_set, batch_size=10)
|
||||
test_dataloader_iter = iter(test_dataloader)
|
||||
images, labels = next(
|
||||
test_dataloader_iter
|
||||
) # only running a batch inference of 10 images
|
||||
predicted_labels = run_image_prediction(model, images)
|
||||
assert torch.equal(predicted_labels, labels)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,325 @@
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import ray
|
||||
import ray.train.torch
|
||||
from ray.data.iterator import (
|
||||
ArrowBatchCollateFn,
|
||||
NumpyBatchCollateFn,
|
||||
PandasBatchCollateFn,
|
||||
)
|
||||
from ray.data.util.torch_utils import (
|
||||
arrow_batch_to_tensors,
|
||||
convert_ndarray_batch_to_torch_tensor_batch,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus_1_gpu():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=1)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _chunk_table_in_half(table: pa.Table) -> pa.Table:
|
||||
num_rows = table.num_rows
|
||||
mid = num_rows // 2
|
||||
|
||||
new_columns = []
|
||||
|
||||
for col in table.itercolumns():
|
||||
# Slice the column in two halves
|
||||
first_half = col.slice(0, mid)
|
||||
second_half = col.slice(mid)
|
||||
|
||||
# Create a chunked array with two chunks
|
||||
chunked_array = pa.chunked_array([first_half, second_half], type=col.type)
|
||||
new_columns.append(chunked_array)
|
||||
|
||||
# Create a new table with the same schema but chunked columns
|
||||
return pa.Table.from_arrays(new_columns, schema=table.schema)
|
||||
|
||||
|
||||
class SingleTensorArrowBatchCollateFn(ArrowBatchCollateFn):
|
||||
"""Collate function that returns only the id column as a tensor."""
|
||||
|
||||
def __call__(self, batch: pa.Table) -> torch.Tensor:
|
||||
"""Return only the id column as a tensor."""
|
||||
assert isinstance(batch, pa.Table)
|
||||
tensor_dict = arrow_batch_to_tensors(batch, combine_chunks=True)
|
||||
return tensor_dict["id"]
|
||||
|
||||
|
||||
class TupleArrowBatchCollateFn(ArrowBatchCollateFn):
|
||||
"""Collate function that returns id and value as a tuple of tensors."""
|
||||
|
||||
def __call__(self, batch: pa.Table) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Return id and value as a tuple of tensors."""
|
||||
assert isinstance(batch, pa.Table)
|
||||
tensor_dict = arrow_batch_to_tensors(batch, combine_chunks=True)
|
||||
return tensor_dict["id"], tensor_dict["value"]
|
||||
|
||||
|
||||
class ListArrowBatchCollateFn(TupleArrowBatchCollateFn):
|
||||
"""Collate function that returns id and value as a list of tensors."""
|
||||
|
||||
def __call__(self, batch: pa.Table) -> List[torch.Tensor]:
|
||||
return list(super().__call__(batch))
|
||||
|
||||
|
||||
class DictArrowBatchCollateFn(ArrowBatchCollateFn):
|
||||
"""Collate function that returns id and value as a dictionary of tensors."""
|
||||
|
||||
def __call__(self, batch: pa.Table) -> Dict[str, torch.Tensor]:
|
||||
"""Return id and value as a dictionary of tensors."""
|
||||
assert isinstance(batch, pa.Table)
|
||||
return arrow_batch_to_tensors(batch, combine_chunks=True)
|
||||
|
||||
|
||||
class ChunkedDictArrowBatchCollateFn(ArrowBatchCollateFn):
|
||||
"""Collate function that returns id and value as a dictionary of chunked tensors."""
|
||||
|
||||
def __call__(self, batch: pa.Table) -> Dict[str, List[torch.Tensor]]:
|
||||
assert isinstance(batch, pa.Table)
|
||||
modified_batch = _chunk_table_in_half(batch)
|
||||
return arrow_batch_to_tensors(modified_batch, combine_chunks=False)
|
||||
|
||||
|
||||
class SingleTensorNumpyBatchCollateFn(NumpyBatchCollateFn):
|
||||
"""Collate function that returns only the id array as a tensor."""
|
||||
|
||||
def __call__(self, batch: Dict[str, np.ndarray]) -> torch.Tensor:
|
||||
"""Return only the id array as a tensor."""
|
||||
assert isinstance(batch, dict)
|
||||
tensor_dict = convert_ndarray_batch_to_torch_tensor_batch(batch)
|
||||
return tensor_dict["id"]
|
||||
|
||||
|
||||
class TupleNumpyBatchCollateFn(NumpyBatchCollateFn):
|
||||
"""Collate function that returns id and value as a tuple of tensors."""
|
||||
|
||||
def __call__(
|
||||
self, batch: Dict[str, np.ndarray]
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
assert isinstance(batch, dict)
|
||||
tensor_dict = convert_ndarray_batch_to_torch_tensor_batch(batch)
|
||||
return tensor_dict["id"], tensor_dict["value"]
|
||||
|
||||
|
||||
class ListNumpyBatchCollateFn(TupleNumpyBatchCollateFn):
|
||||
"""Collate function that returns id and value as a list of tensors."""
|
||||
|
||||
def __call__(self, batch: Dict[str, np.ndarray]) -> List[torch.Tensor]:
|
||||
return list(super().__call__(batch))
|
||||
|
||||
|
||||
class DictNumpyBatchCollateFn(NumpyBatchCollateFn):
|
||||
"""Collate function that returns id and value as a dictionary of tensors."""
|
||||
|
||||
def __call__(self, batch: Dict[str, np.ndarray]) -> Dict[str, torch.Tensor]:
|
||||
assert isinstance(batch, dict)
|
||||
return convert_ndarray_batch_to_torch_tensor_batch(batch)
|
||||
|
||||
|
||||
class BasePandasBatchCollateFn(PandasBatchCollateFn):
|
||||
"""Base class for Pandas batch collate functions that process and convert to tensors.
|
||||
|
||||
This class provides common functionality for processing Pandas DataFrames and converting
|
||||
them to PyTorch tensors. It handles device placement and dtype conversion.
|
||||
|
||||
Args:
|
||||
device: Optional device to place tensors on. Can be a string (e.g. "cpu", "cuda:0")
|
||||
or a torch.device object.
|
||||
"""
|
||||
|
||||
device: Optional[str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Optional[Union[str, torch.device]] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if isinstance(device, str):
|
||||
self.device = torch.device(device)
|
||||
else:
|
||||
self.device = device
|
||||
|
||||
def _process_batch(self, batch: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Process the batch by adding 5 to the id column.
|
||||
|
||||
Args:
|
||||
batch: Input Pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
A new DataFrame with modified "id" column and original "value" column.
|
||||
"""
|
||||
return pd.DataFrame({"id": batch["id"] + 5, "value": batch["id"]})
|
||||
|
||||
def _get_tensors(self, batch: pd.DataFrame) -> Dict[str, torch.Tensor]:
|
||||
"""Convert batch to tensors.
|
||||
|
||||
Args:
|
||||
batch: Input Pandas DataFrame to convert to tensors.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping column names to PyTorch tensors.
|
||||
"""
|
||||
return convert_ndarray_batch_to_torch_tensor_batch(
|
||||
batch.to_dict("series"), dtypes=None, device=None
|
||||
)
|
||||
|
||||
|
||||
class SingleTensorPandasBatchCollateFn(PandasBatchCollateFn):
|
||||
"""Collate function that returns only the id column as a tensor."""
|
||||
|
||||
def __call__(self, batch: pd.DataFrame) -> torch.Tensor:
|
||||
tensor_dict = convert_ndarray_batch_to_torch_tensor_batch(
|
||||
batch.to_dict("series")
|
||||
)
|
||||
return tensor_dict["id"]
|
||||
|
||||
|
||||
class TuplePandasBatchCollateFn(PandasBatchCollateFn):
|
||||
"""Collate function that returns id and value as a tuple of tensors."""
|
||||
|
||||
def __call__(self, batch: pd.DataFrame) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
tensor_dict = convert_ndarray_batch_to_torch_tensor_batch(
|
||||
batch.to_dict("series")
|
||||
)
|
||||
return tensor_dict["id"], tensor_dict["value"]
|
||||
|
||||
|
||||
class ListPandasBatchCollateFn(TuplePandasBatchCollateFn):
|
||||
"""Collate function that returns id and value as a list of tensors."""
|
||||
|
||||
def __call__(self, batch: pd.DataFrame) -> List[torch.Tensor]:
|
||||
return list(super().__call__(batch))
|
||||
|
||||
|
||||
class DictPandasBatchCollateFn(PandasBatchCollateFn):
|
||||
"""Collate function that returns id and value as a dictionary of tensors."""
|
||||
|
||||
def __call__(self, batch: pd.DataFrame) -> Dict[str, torch.Tensor]:
|
||||
return convert_ndarray_batch_to_torch_tensor_batch(batch.to_dict("series"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collate_fn_map():
|
||||
"""Fixture that provides Arrow, Numpy, Pandas custom collate functions."""
|
||||
|
||||
return {
|
||||
"arrow": {
|
||||
"default": None,
|
||||
"single": SingleTensorArrowBatchCollateFn(),
|
||||
"tuple": TupleArrowBatchCollateFn(),
|
||||
"list": ListArrowBatchCollateFn(),
|
||||
"dict": DictArrowBatchCollateFn(),
|
||||
"chunked_dict": ChunkedDictArrowBatchCollateFn(),
|
||||
},
|
||||
"numpy": {
|
||||
"single": SingleTensorNumpyBatchCollateFn(),
|
||||
"tuple": TupleNumpyBatchCollateFn(),
|
||||
"dict": DictNumpyBatchCollateFn(),
|
||||
"list": ListNumpyBatchCollateFn(),
|
||||
},
|
||||
"pandas": {
|
||||
"single": SingleTensorPandasBatchCollateFn(),
|
||||
"tuple": TuplePandasBatchCollateFn(),
|
||||
"dict": DictPandasBatchCollateFn(),
|
||||
"list": ListPandasBatchCollateFn(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("collate_batch_type", ["arrow", "numpy", "pandas"])
|
||||
@pytest.mark.parametrize(
|
||||
"return_type", ["single", "tuple", "dict", "list", "chunked_dict", "default"]
|
||||
)
|
||||
@pytest.mark.parametrize("device", ["cpu", "cuda:0"])
|
||||
@pytest.mark.parametrize("pin_memory", [True, False])
|
||||
def test_custom_batch_collate_fn(
|
||||
ray_start_4_cpus_1_gpu,
|
||||
monkeypatch,
|
||||
collate_batch_type,
|
||||
return_type,
|
||||
device,
|
||||
collate_fn_map,
|
||||
pin_memory,
|
||||
):
|
||||
"""Tests that custom batch collate functions can be used to modify
|
||||
the batch before it is converted to a PyTorch tensor.
|
||||
|
||||
Note that the collate_fn doesn't move the tensors to the device --
|
||||
that happens in the iterator (finalize_fn).
|
||||
"""
|
||||
# Skip GPU tests if CUDA is not available
|
||||
if device == "cuda:0" and not torch.cuda.is_available():
|
||||
pytest.skip("CUDA is not available")
|
||||
|
||||
# Skip pin_memory tests if CUDA is not available
|
||||
if pin_memory and not torch.cuda.is_available():
|
||||
pytest.skip("pin_memory is set to True, but CUDA is not available.")
|
||||
|
||||
# Skip tests if pin_memory is set to True and the collate function is not the
|
||||
# DefaultCollateFn.
|
||||
if pin_memory and not (collate_batch_type == "arrow" and return_type == "default"):
|
||||
pytest.skip(
|
||||
"pin_memory is set to True, but the collate function is not the DefaultCollateFn."
|
||||
)
|
||||
|
||||
collate_fn = collate_fn_map[collate_batch_type].get(return_type)
|
||||
if collate_fn is None:
|
||||
pytest.skip(
|
||||
f"Collate function not found for ({collate_batch_type}, {return_type})"
|
||||
)
|
||||
|
||||
# Set the device that's returned by device="auto" -> get_device()
|
||||
# This is used in `finalize_fn` to move the tensors to the correct device.
|
||||
device = torch.device(device)
|
||||
monkeypatch.setattr(ray.train.utils, "_in_ray_train_worker", lambda: True)
|
||||
monkeypatch.setattr(ray.train.torch, "get_device", lambda: device)
|
||||
|
||||
ds = ray.data.from_items(
|
||||
[{"id": i + 5, "value": i} for i in range(5)],
|
||||
)
|
||||
it = ds.iterator()
|
||||
|
||||
for batch in it.iter_torch_batches(collate_fn=collate_fn, pin_memory=pin_memory):
|
||||
if return_type == "single":
|
||||
assert isinstance(batch, torch.Tensor)
|
||||
assert sorted(batch.tolist()) == list(range(5, 10))
|
||||
assert batch.device == device
|
||||
if pin_memory and device.type == "cpu":
|
||||
assert batch.is_pinned()
|
||||
elif return_type == "dict" or return_type == "chunked_dict":
|
||||
# Chunked dicts get concatenated to single Tensors on the device,
|
||||
# so the assertions are shared with the dict case.
|
||||
assert isinstance(batch, dict)
|
||||
assert sorted(batch["id"].tolist()) == list(range(5, 10))
|
||||
assert sorted(batch["value"].tolist()) == list(range(5))
|
||||
assert batch["id"].device == device
|
||||
assert batch["value"].device == device
|
||||
if pin_memory and device.type == "cpu":
|
||||
assert batch["id"].is_pinned()
|
||||
assert batch["value"].is_pinned()
|
||||
else: # tuple or list
|
||||
assert isinstance(batch, (tuple, list))
|
||||
assert len(batch) == 2
|
||||
assert sorted(batch[0].tolist()) == list(range(5, 10))
|
||||
assert sorted(batch[1].tolist()) == list(range(5))
|
||||
assert batch[0].device == device
|
||||
assert batch[1].device == device
|
||||
if pin_memory and device.type == "cpu":
|
||||
assert batch[0].is_pinned()
|
||||
assert batch[1].is_pinned()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,218 @@
|
||||
import math
|
||||
from unittest import mock
|
||||
|
||||
import lightgbm as lgbm
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sklearn.datasets import load_breast_cancer
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
import ray
|
||||
from ray import tune
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
from ray.train.lightgbm import LightGBMTrainer, RayTrainReportCallback
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_6_cpus():
|
||||
address_info = ray.init(num_cpus=6)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_8_cpus():
|
||||
address_info = ray.init(num_cpus=8)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
scale_config = ScalingConfig(num_workers=2)
|
||||
|
||||
data_raw = load_breast_cancer()
|
||||
dataset_df = pd.DataFrame(data_raw["data"], columns=data_raw["feature_names"])
|
||||
dataset_df["target"] = data_raw["target"]
|
||||
train_df, test_df = train_test_split(dataset_df, test_size=0.3)
|
||||
|
||||
params = {
|
||||
"objective": "binary",
|
||||
"metric": ["binary_logloss", "binary_error"],
|
||||
}
|
||||
|
||||
|
||||
def get_num_trees(booster: lgbm.Booster) -> int:
|
||||
return booster.current_iteration()
|
||||
|
||||
|
||||
def test_fit_with_categoricals(ray_start_6_cpus):
|
||||
train_df_with_cat = train_df.copy()
|
||||
test_df_with_cat = test_df.copy()
|
||||
train_df_with_cat["categorical_column"] = pd.Series(
|
||||
(["A", "B"] * math.ceil(len(train_df_with_cat) / 2))[: len(train_df_with_cat)]
|
||||
).astype("category")
|
||||
test_df_with_cat["categorical_column"] = pd.Series(
|
||||
(["A", "B"] * math.ceil(len(test_df_with_cat) / 2))[: len(test_df_with_cat)]
|
||||
).astype("category")
|
||||
|
||||
train_dataset = ray.data.from_pandas(train_df_with_cat)
|
||||
valid_dataset = ray.data.from_pandas(test_df_with_cat)
|
||||
trainer = LightGBMTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
checkpoint = result.checkpoint
|
||||
model = LightGBMTrainer.get_model(checkpoint)
|
||||
assert model.pandas_categorical == [["A", "B"]]
|
||||
|
||||
|
||||
def test_resume_from_checkpoint(ray_start_6_cpus, tmpdir):
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = LightGBMTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=5,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
model = LightGBMTrainer.get_model(result.checkpoint)
|
||||
assert get_num_trees(model) == 5
|
||||
|
||||
trainer = LightGBMTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=10,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
resume_from_checkpoint=result.checkpoint,
|
||||
)
|
||||
result = trainer.fit()
|
||||
checkpoint = result.checkpoint
|
||||
model = LightGBMTrainer.get_model(checkpoint)
|
||||
assert get_num_trees(model) == 10
|
||||
|
||||
|
||||
def test_fit_with_arrow_backed_pandas_dtypes(ray_start_6_cpus):
|
||||
# `from_items` produces Arrow-backed blocks, so `to_pandas()` inside the
|
||||
# trainer returns Arrow-backed dtypes — the regression path this test guards.
|
||||
train_dataset = ray.data.from_items(train_df.to_dict("records"))
|
||||
valid_dataset = ray.data.from_items(test_df.to_dict("records"))
|
||||
|
||||
trainer = LightGBMTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
model = LightGBMTrainer.get_model(result.checkpoint)
|
||||
assert get_num_trees(model) == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"freq_end_expected",
|
||||
[
|
||||
# With num_boost_round=25 with 0 indexing, the checkpoints will be at:
|
||||
(4, True, 7), # 3, 7, 11, 15, 19, 23, 24 (end)
|
||||
(4, False, 6), # 3, 7, 11, 15, 19, 23
|
||||
(5, True, 5), # 4, 9, 14, 19, 24
|
||||
(0, True, 1), # 24 (end)
|
||||
(0, False, 0),
|
||||
],
|
||||
)
|
||||
def test_checkpoint_freq(ray_start_6_cpus, freq_end_expected):
|
||||
freq, end, expected = freq_end_expected
|
||||
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = LightGBMTrainer(
|
||||
run_config=ray.train.RunConfig(
|
||||
checkpoint_config=ray.train.CheckpointConfig(
|
||||
checkpoint_frequency=freq, checkpoint_at_end=end
|
||||
)
|
||||
),
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=25,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
# Assert number of checkpoints
|
||||
assert len(result.best_checkpoints) == expected, str(
|
||||
[(metrics["training_iteration"], cp) for cp, metrics in result.best_checkpoints]
|
||||
)
|
||||
|
||||
# Assert checkpoint numbers are increasing
|
||||
cp_paths = [cp.path for cp, _ in result.best_checkpoints]
|
||||
assert cp_paths == sorted(cp_paths), str(cp_paths)
|
||||
|
||||
|
||||
def test_tune(ray_start_8_cpus):
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = LightGBMTrainer(
|
||||
scaling_config=ScalingConfig(num_workers=2, resources_per_worker={"CPU": 1}),
|
||||
label_column="target",
|
||||
params={**params, "max_depth": 1},
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
|
||||
tuner = tune.Tuner(
|
||||
trainer,
|
||||
param_space={"params": {"max_depth": tune.grid_search([2, 4])}},
|
||||
)
|
||||
results = tuner.fit()
|
||||
assert sorted([r.config["params"]["max_depth"] for r in results]) == [2, 4]
|
||||
|
||||
|
||||
def test_validation(ray_start_6_cpus):
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
with pytest.raises(ValueError, match=TRAIN_DATASET_KEY):
|
||||
LightGBMTrainer(
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={"valid": valid_dataset},
|
||||
)
|
||||
with pytest.raises(ValueError, match="label_column"):
|
||||
LightGBMTrainer(
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
datasets={"train": valid_dataset},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [None, 0, 1])
|
||||
def test_checkpoint_only_on_rank0(rank):
|
||||
"""Tests that the callback only reports checkpoints on rank 0,
|
||||
or if the rank is not available (Tune usage)."""
|
||||
callback = RayTrainReportCallback(frequency=2, checkpoint_at_end=True)
|
||||
|
||||
booster = mock.MagicMock()
|
||||
|
||||
with mock.patch("ray.train.get_context") as mock_get_context:
|
||||
mock_context = mock.MagicMock()
|
||||
mock_context.get_world_rank.return_value = rank
|
||||
mock_get_context.return_value = mock_context
|
||||
|
||||
with callback._get_checkpoint(booster) as checkpoint:
|
||||
if rank in (0, None):
|
||||
assert checkpoint
|
||||
else:
|
||||
assert not checkpoint
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
class TestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return TestBackend
|
||||
|
||||
|
||||
class TestBackend(Backend):
|
||||
def on_start(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
def on_shutdown(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
|
||||
def test_run(ray_start_4_cpus):
|
||||
"""Tests that Train can be run without any specific backends."""
|
||||
num_workers = 2
|
||||
key = "value"
|
||||
value = 1
|
||||
config = TestConfig()
|
||||
|
||||
def train_func():
|
||||
checkpoint = train.get_checkpoint()
|
||||
checkpoint_dict = load_dict_checkpoint(checkpoint)
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
train.report(metrics=checkpoint_dict, checkpoint=checkpoint)
|
||||
else:
|
||||
train.report(metrics=checkpoint_dict)
|
||||
return checkpoint_dict[key]
|
||||
|
||||
with create_dict_checkpoint({key: value}) as checkpoint:
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=config,
|
||||
resume_from_checkpoint=checkpoint,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
)
|
||||
results = trainer.fit()
|
||||
|
||||
assert load_dict_checkpoint(results.checkpoint) == load_dict_checkpoint(
|
||||
checkpoint
|
||||
)
|
||||
|
||||
|
||||
def test_failure():
|
||||
"""Tests that backend frameworks and non-critical libraries are not imported."""
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import torch # noqa: F401
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import tensorflow # noqa: F401
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import horovod # noqa: F401
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import accelerate # noqa: F401
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import transformers # noqa: F401
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import xgboost # noqa: F401
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,636 @@
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray._common.test_utils import simulate_s3_bucket
|
||||
from ray.air._internal.uri_utils import URI
|
||||
from ray.air.constants import EXPR_RESULT_FILE
|
||||
from ray.train._checkpoint import Checkpoint
|
||||
from ray.train._internal.storage import (
|
||||
StorageContext,
|
||||
_delete_fs_path,
|
||||
_download_from_fs_path,
|
||||
)
|
||||
from ray.train.base_trainer import TrainingFailedError
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.tune.trainable.trainable import _DICT_CHECKPOINT_FILE_NAME
|
||||
|
||||
|
||||
class TestConstants:
|
||||
NUM_ITERATIONS = 6 # == num_checkpoints == num_artifacts
|
||||
NUM_TRIALS = 2
|
||||
NUM_WORKERS = 3
|
||||
|
||||
SCORE_KEY = "score"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mock_s3_bucket_uri():
|
||||
port = 5002
|
||||
region = "us-west-2"
|
||||
with simulate_s3_bucket(port=port, region=region) as s3_uri:
|
||||
import boto3
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3", region_name=region, endpoint_url=f"http://localhost:{port}"
|
||||
)
|
||||
# Bucket name will be autogenerated/unique per test
|
||||
bucket_name = URI(s3_uri).name
|
||||
s3.create_bucket(
|
||||
Bucket=bucket_name,
|
||||
CreateBucketConfiguration={"LocationConstraint": region},
|
||||
)
|
||||
# Disable server HTTP request logging
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
yield URI(s3_uri)
|
||||
logging.getLogger("werkzeug").setLevel(logging.INFO)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def dummy_context_manager(*args, **kwargs):
|
||||
yield "dummy value"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def ray_start_4_cpus():
|
||||
ray.init(num_cpus=4)
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _create_mock_custom_fs(custom_fs_root_dir: Path) -> pyarrow.fs.FileSystem:
|
||||
from fsspec.implementations.dirfs import DirFileSystem
|
||||
from fsspec.implementations.local import LocalFileSystem
|
||||
|
||||
custom_fs_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
storage_filesystem = pyarrow.fs.PyFileSystem(
|
||||
pyarrow.fs.FSSpecHandler(
|
||||
DirFileSystem(path=str(custom_fs_root_dir), fs=LocalFileSystem())
|
||||
)
|
||||
)
|
||||
return storage_filesystem
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _resolve_storage_type(
|
||||
storage_path_type: str, tmp_path: Path
|
||||
) -> Tuple[str, Optional[pyarrow.fs.FileSystem]]:
|
||||
storage_path, storage_filesystem = None, None
|
||||
|
||||
context_manager = (
|
||||
mock_s3_bucket_uri if storage_path_type == "cloud" else dummy_context_manager
|
||||
)
|
||||
|
||||
with context_manager() as cloud_storage_path:
|
||||
if storage_path_type == "nfs":
|
||||
storage_path = str(tmp_path / "fake_nfs")
|
||||
elif storage_path_type == "cloud":
|
||||
storage_path = str(cloud_storage_path)
|
||||
elif storage_path_type == "custom_fs":
|
||||
storage_path = "mock_bucket"
|
||||
storage_filesystem = _create_mock_custom_fs(tmp_path / "custom_fs")
|
||||
|
||||
yield storage_path, storage_filesystem
|
||||
|
||||
|
||||
def _get_local_inspect_dir(
|
||||
root_local_path: Path,
|
||||
storage_path: str,
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem],
|
||||
storage_local_path: Path = None,
|
||||
) -> Tuple[Path, str]:
|
||||
"""Downloads the storage path -> local dir for inspecting contents.
|
||||
|
||||
Args:
|
||||
root_local_path: Local directory to use as the inspect root.
|
||||
storage_path: The storage path or URI to download from.
|
||||
storage_filesystem: Optional custom filesystem to use.
|
||||
storage_local_path: Local path that ``storage_path`` mirrors on disk
|
||||
when no remote storage is configured.
|
||||
|
||||
Returns:
|
||||
Tuple: (local_inspect_dir, storage_fs_path), where storage_fs_path
|
||||
is the path to the storage path on the filesystem (e.g., prefix stripped).
|
||||
This is used to check the correctness of paths returned from `Result`'s,
|
||||
since URIs are hard to do comparisons with.
|
||||
"""
|
||||
local_inspect_dir = root_local_path / "inspect"
|
||||
if storage_path:
|
||||
if storage_filesystem:
|
||||
fs, storage_fs_path = storage_filesystem, storage_path
|
||||
else:
|
||||
fs, storage_fs_path = pyarrow.fs.FileSystem.from_uri(storage_path)
|
||||
_download_from_fs_path(
|
||||
fs=fs, fs_path=storage_fs_path, local_path=str(local_inspect_dir)
|
||||
)
|
||||
else:
|
||||
fs, storage_fs_path = pyarrow.fs.LocalFileSystem(), str(storage_local_path)
|
||||
local_inspect_dir = storage_local_path
|
||||
|
||||
return local_inspect_dir, storage_fs_path
|
||||
|
||||
|
||||
def _get_checkpoint_index(checkpoint_dir_name: str) -> int:
|
||||
"""Gets the checkpoint index from the checkpoint directory name."""
|
||||
return int(checkpoint_dir_name.split("_")[-1])
|
||||
|
||||
|
||||
def _create_checkpoint_shard_filename(rank_str: str) -> str:
|
||||
return f"checkpoint_shard-rank={rank_str}.pkl"
|
||||
|
||||
|
||||
def _get_checkpoint_shard_rank(checkpoint_shard_filename: str) -> int:
|
||||
"""Get the checkpoint shard rank from the filename."""
|
||||
pattern = _create_checkpoint_shard_filename(r"(\d+)")
|
||||
match = re.search(pattern, checkpoint_shard_filename)
|
||||
assert match
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def train_fn(config):
|
||||
in_trainer = config.get("in_trainer", False)
|
||||
if in_trainer:
|
||||
from ray.train._internal.session import _TrainSession, get_session
|
||||
|
||||
train_session = get_session()
|
||||
|
||||
assert isinstance(train_session, _TrainSession)
|
||||
assert train_session.storage
|
||||
assert train_session.storage.checkpoint_fs_path
|
||||
|
||||
# Check that the working dir for each worker is the shared trial dir.
|
||||
assert (
|
||||
Path.cwd() == Path(train_session.storage.trial_working_directory).resolve()
|
||||
)
|
||||
|
||||
start = 0
|
||||
|
||||
checkpoint = train.get_checkpoint()
|
||||
if checkpoint:
|
||||
custom_restore_fn = config.get("custom_restore_fn")
|
||||
if custom_restore_fn:
|
||||
state = custom_restore_fn(checkpoint)
|
||||
else:
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
with open(os.path.join(checkpoint_dir, "checkpoint.pkl"), "rb") as f:
|
||||
state = pickle.load(f)
|
||||
print("Loaded back state from checkpoint:", state)
|
||||
start = state["iter"] + 1
|
||||
|
||||
for i in range(start, config.get("num_iterations", 5)):
|
||||
time.sleep(config.get("time_per_iter", 0.25))
|
||||
|
||||
metrics = {"iter": i, TestConstants.SCORE_KEY: i}
|
||||
|
||||
# Save an artifact in the local trial dir.
|
||||
rank = train.get_context().get_world_rank()
|
||||
artifact_file_name = (
|
||||
f"artifact-rank={rank}-iter={i}.txt"
|
||||
if in_trainer
|
||||
else f"artifact-iter={i}.txt"
|
||||
)
|
||||
with open(artifact_file_name, "w") as f:
|
||||
f.write(f"{i}")
|
||||
|
||||
if in_trainer and train.get_context().get_world_rank() in config.get(
|
||||
"no_checkpoint_ranks", []
|
||||
):
|
||||
train.report(metrics)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with open(os.path.join(temp_dir, "checkpoint.pkl"), "wb") as f:
|
||||
pickle.dump({"iter": i}, f)
|
||||
|
||||
if in_trainer:
|
||||
checkpoint_file_name = _create_checkpoint_shard_filename(str(rank))
|
||||
with open(os.path.join(temp_dir, checkpoint_file_name), "wb") as f:
|
||||
pickle.dump({"iter": i}, f)
|
||||
|
||||
with config.get("custom_save_fn", dummy_context_manager)(temp_dir):
|
||||
train.report(
|
||||
metrics, checkpoint=Checkpoint.from_directory(temp_dir)
|
||||
)
|
||||
# `train.report` should not have deleted this!
|
||||
assert os.path.exists(temp_dir)
|
||||
|
||||
if i in config.get("fail_iters", []):
|
||||
raise RuntimeError(f"Failing on iter={i}!!")
|
||||
|
||||
|
||||
class ClassTrainable(tune.Trainable):
|
||||
"""Implement (almost) the same thing as `train_fn` but as a class."""
|
||||
|
||||
def setup(self, config):
|
||||
# Save some markers in the trial dir.
|
||||
tmp_path = config.get("tmp_path")
|
||||
self.fail_markers = {
|
||||
i: tmp_path / f"fail_marker_{self.trial_id}_iter={i}"
|
||||
for i in config.get("fail_iters", [])
|
||||
}
|
||||
setup_marker = tmp_path / f"setup_marker_{self.trial_id}"
|
||||
if not setup_marker.exists():
|
||||
for marker in self.fail_markers.values():
|
||||
marker.touch()
|
||||
setup_marker.touch()
|
||||
|
||||
self.save_as_dict = config.get("save_checkpoint_as_dict", False)
|
||||
|
||||
def step(self) -> dict:
|
||||
if self.iteration in self.fail_markers:
|
||||
marker = self.fail_markers[self.iteration]
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
raise RuntimeError(f"Failing on iter={self.iteration}")
|
||||
|
||||
# Save an artifact in the local trial dir.
|
||||
artifact_file_name = f"artifact-iter={self.iteration}.txt"
|
||||
with open(artifact_file_name, "w") as f:
|
||||
f.write(f"{self.iteration}")
|
||||
|
||||
return {
|
||||
"score": 1,
|
||||
"done": self.iteration >= self.config.get("num_iterations") - 1,
|
||||
"should_checkpoint": True,
|
||||
}
|
||||
|
||||
def save_checkpoint(self, temp_checkpoint_dir) -> str:
|
||||
if self.save_as_dict:
|
||||
return {"dummy": "data"}
|
||||
(Path(temp_checkpoint_dir) / "checkpoint.pkl").write_text("dummy")
|
||||
return temp_checkpoint_dir
|
||||
|
||||
def load_checkpoint(self, checkpoint_dict_or_path):
|
||||
print("Loading state from:", checkpoint_dict_or_path)
|
||||
print("At iteration =", self.iteration)
|
||||
if self.save_as_dict:
|
||||
assert checkpoint_dict_or_path == {"dummy": "data"}
|
||||
else:
|
||||
assert (
|
||||
Path(checkpoint_dict_or_path) / "checkpoint.pkl"
|
||||
).read_text() == "dummy"
|
||||
|
||||
|
||||
def _resume_from_checkpoint(
|
||||
checkpoint: Checkpoint,
|
||||
expected_state: dict,
|
||||
storage_path: Optional[str] = None,
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
):
|
||||
print(f"\nStarting run with `resume_from_checkpoint`: {checkpoint}\n")
|
||||
|
||||
def assert_fn(config):
|
||||
checkpoint_to_check = train.get_checkpoint()
|
||||
with checkpoint_to_check.as_directory() as checkpoint_dir:
|
||||
with open(os.path.join(checkpoint_dir, "checkpoint.pkl"), "rb") as f:
|
||||
state = pickle.load(f)
|
||||
|
||||
print("Loaded state from `resume_from_checkpoint`:", state)
|
||||
print("Expected state:", expected_state)
|
||||
assert state == expected_state, (state, expected_state)
|
||||
|
||||
dummy_ckpt = tempfile.mkdtemp()
|
||||
with open(os.path.join(dummy_ckpt, "dummy.txt"), "w") as f:
|
||||
f.write("data")
|
||||
train.report({"dummy": 1}, checkpoint=Checkpoint.from_directory(dummy_ckpt))
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
assert_fn,
|
||||
scaling_config=train.ScalingConfig(num_workers=2),
|
||||
run_config=train.RunConfig(
|
||||
name="test_resume_from_checkpoint",
|
||||
storage_path=storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
),
|
||||
resume_from_checkpoint=checkpoint,
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
# Make sure that the checkpoint indexing starts from scratch.
|
||||
assert Path(
|
||||
result.checkpoint.path
|
||||
).name == StorageContext._make_checkpoint_dir_name(0)
|
||||
|
||||
# Clean up this run's experiment directory immediately after.
|
||||
_delete_fs_path(result.filesystem, Path(result.path).parent.as_posix())
|
||||
|
||||
|
||||
def _assert_storage_contents(
|
||||
local_inspect_dir: Path,
|
||||
exp_name: str,
|
||||
checkpoint_config: train.CheckpointConfig,
|
||||
trainable_name: str,
|
||||
test_trainer: bool,
|
||||
no_checkpoint_ranks: List[int] = None,
|
||||
constants: type = TestConstants,
|
||||
):
|
||||
no_checkpoint_ranks = no_checkpoint_ranks or []
|
||||
|
||||
# Second, inspect the contents of the storage path
|
||||
storage_path_ls = list(local_inspect_dir.glob("*"))
|
||||
assert len(storage_path_ls) == 1 # Only expect 1 experiment dir
|
||||
exp_dir = storage_path_ls[0]
|
||||
assert exp_dir.name == exp_name
|
||||
|
||||
# Files synced by the driver
|
||||
assert len(list(exp_dir.glob("tuner.pkl"))) == 1
|
||||
if test_trainer:
|
||||
assert len(list(exp_dir.glob("trainer.pkl"))) == 1
|
||||
# 2 copies of these files:
|
||||
# 1 for the initial run, and 1 for the manually restored run.
|
||||
assert len(list(exp_dir.glob("basic-variant-state-*"))) == 2
|
||||
assert len(list(exp_dir.glob("experiment_state-*"))) == 2
|
||||
|
||||
# Files synced by the worker
|
||||
assert (
|
||||
len(list(exp_dir.glob(f"{trainable_name}*"))) == 1
|
||||
if test_trainer
|
||||
else constants.NUM_TRIALS
|
||||
)
|
||||
for trial_dir in exp_dir.glob(f"{trainable_name}*"):
|
||||
# If set, expect num_to_keep. Otherwise, expect to see all of them.
|
||||
expected_num_checkpoints = (
|
||||
checkpoint_config.num_to_keep or constants.NUM_ITERATIONS
|
||||
)
|
||||
|
||||
assert len(list(trial_dir.glob("checkpoint_*"))) == expected_num_checkpoints
|
||||
checkpoint_idxs = sorted(
|
||||
[
|
||||
_get_checkpoint_index(checkpoint_dir.name)
|
||||
for checkpoint_dir in trial_dir.glob("checkpoint_*")
|
||||
]
|
||||
)
|
||||
# Ex: If num_to_keep=2 out of 6 total checkpoints,
|
||||
# expect checkpoint_004 and checkpoint_005.
|
||||
assert checkpoint_idxs == list(
|
||||
range(
|
||||
constants.NUM_ITERATIONS - expected_num_checkpoints,
|
||||
constants.NUM_ITERATIONS,
|
||||
)
|
||||
)
|
||||
|
||||
for checkpoint_dir in trial_dir.glob("checkpoint_*"):
|
||||
# 1 shared checkpoint.pkl file, written by the trainable / all workers.
|
||||
assert (
|
||||
len(list(checkpoint_dir.glob("checkpoint.pkl"))) == 1
|
||||
# NOTE: Dict checkpoint is only for the ClassTrainable.
|
||||
or len(list(checkpoint_dir.glob(_DICT_CHECKPOINT_FILE_NAME))) == 1
|
||||
)
|
||||
if test_trainer:
|
||||
# 1 checkpoint shard per worker.
|
||||
# Unless the worker did not report a checkpoint (no_checkpoint_ranks).
|
||||
assert {
|
||||
_get_checkpoint_shard_rank(checkpoint_shard.name)
|
||||
for checkpoint_shard in checkpoint_dir.glob(
|
||||
"checkpoint_shard-*.pkl"
|
||||
)
|
||||
} == {
|
||||
i
|
||||
for i in range(constants.NUM_WORKERS)
|
||||
if i not in no_checkpoint_ranks
|
||||
}
|
||||
|
||||
if test_trainer:
|
||||
expected_num_artifacts = constants.NUM_ITERATIONS * constants.NUM_WORKERS
|
||||
else:
|
||||
expected_num_artifacts = constants.NUM_ITERATIONS
|
||||
assert len(list(trial_dir.glob("artifact-*"))) == expected_num_artifacts
|
||||
|
||||
# NOTE: This result file is synced by the driver.
|
||||
assert len(list(trial_dir.glob(EXPR_RESULT_FILE))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trainable", [train_fn, ClassTrainable])
|
||||
@pytest.mark.parametrize("storage_path_type", ["nfs", "cloud", "custom_fs"])
|
||||
@pytest.mark.parametrize(
|
||||
"checkpoint_config",
|
||||
[train.CheckpointConfig(), train.CheckpointConfig(num_to_keep=2)],
|
||||
)
|
||||
def test_tuner(
|
||||
tmp_path,
|
||||
trainable,
|
||||
storage_path_type,
|
||||
checkpoint_config: train.CheckpointConfig,
|
||||
):
|
||||
"""End-to-end test that the new persistence mode works with the Tuner API.
|
||||
This test covers many `storage_path_type` options:
|
||||
- storage_path=None --> save locally to the default local path (e.g., ~/ray_results)
|
||||
- storage_path="nfs" --> save locally to a fake NFS path
|
||||
- storage_path="cloud" --> save to a mock S3 bucket
|
||||
- storage_path="custom_fs" --> save to a custom pyarrow filesystem
|
||||
- The custom fs is a local filesystem that appends a path prefix to every path.
|
||||
|
||||
This is the expected output at the storage path:
|
||||
|
||||
{storage_path}/{exp_name}
|
||||
├── tuner.pkl <- Driver artifacts (global experiment state)
|
||||
├── basic-variant-state.json
|
||||
├── experiment_state.json
|
||||
├── train_fn_a2b9e_00000_0_...
|
||||
│ ├── artifact-iter=0.txt <- Trial artifacts
|
||||
│ ├── ...
|
||||
│ ├── checkpoint_000000 <- Trial checkpoints
|
||||
│ │ └── checkpoint.pkl
|
||||
│ ├── ...
|
||||
│ ├── events.out.tfevents... <- Driver artifacts (trial results)
|
||||
│ ├── params.json
|
||||
│ ├── params.pkl
|
||||
│ ├── progress.csv
|
||||
│ └── result.json
|
||||
└── train_fn_a2b9e_00001_1_...
|
||||
└── ... <- Same as above
|
||||
"""
|
||||
exp_name = f"tuner_persistence_test-{uuid.uuid4().hex}"
|
||||
|
||||
with _resolve_storage_type(storage_path_type, tmp_path) as (
|
||||
storage_path,
|
||||
storage_filesystem,
|
||||
):
|
||||
run_config = train.RunConfig(
|
||||
storage_path=storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
name=exp_name,
|
||||
verbose=0,
|
||||
failure_config=train.FailureConfig(max_failures=1),
|
||||
checkpoint_config=checkpoint_config,
|
||||
sync_config=train.SyncConfig(sync_artifacts=True),
|
||||
)
|
||||
tuner = tune.Tuner(
|
||||
trainable,
|
||||
param_space={
|
||||
"num_iterations": TestConstants.NUM_ITERATIONS,
|
||||
"fail_iters": [2, 4],
|
||||
# NOTE: This param is only used in the ClassTrainable.
|
||||
"save_checkpoint_as_dict": tune.grid_search([True, False]),
|
||||
"tmp_path": tmp_path,
|
||||
},
|
||||
run_config=run_config,
|
||||
# 2 samples (from the grid search). Run 1 at at time to test actor reuse
|
||||
tune_config=tune.TuneConfig(num_samples=1, max_concurrent_trials=1),
|
||||
)
|
||||
result_grid = tuner.fit()
|
||||
assert result_grid.errors
|
||||
|
||||
restored_tuner = tune.Tuner.restore(
|
||||
path=str(URI(run_config.storage_path) / exp_name),
|
||||
trainable=trainable,
|
||||
storage_filesystem=storage_filesystem,
|
||||
resume_errored=True,
|
||||
)
|
||||
result_grid = restored_tuner.fit()
|
||||
assert not result_grid.errors
|
||||
|
||||
local_inspect_dir, storage_fs_path = _get_local_inspect_dir(
|
||||
root_local_path=tmp_path,
|
||||
storage_path=run_config.storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
|
||||
# First, check that the ResultGrid returns the correct paths.
|
||||
print(result_grid)
|
||||
experiment_fs_path = result_grid.experiment_path
|
||||
assert isinstance(result_grid.filesystem, pyarrow.fs.FileSystem), result_grid
|
||||
assert experiment_fs_path == os.path.join(storage_fs_path, exp_name)
|
||||
assert len(result_grid) == TestConstants.NUM_TRIALS
|
||||
for result in result_grid:
|
||||
trial_fs_path = result.path
|
||||
assert isinstance(result.filesystem, pyarrow.fs.FileSystem), result
|
||||
assert trial_fs_path.startswith(experiment_fs_path)
|
||||
for checkpoint, _ in result.best_checkpoints:
|
||||
assert checkpoint.path.startswith(trial_fs_path)
|
||||
|
||||
# Next, inspect the storage path contents.
|
||||
_assert_storage_contents(
|
||||
local_inspect_dir,
|
||||
exp_name,
|
||||
checkpoint_config,
|
||||
trainable_name=trainable.__name__,
|
||||
test_trainer=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("storage_path_type", ["nfs", "cloud", "custom_fs"])
|
||||
@pytest.mark.parametrize(
|
||||
"checkpoint_config",
|
||||
[
|
||||
train.CheckpointConfig(),
|
||||
train.CheckpointConfig(
|
||||
num_to_keep=1,
|
||||
checkpoint_score_attribute=TestConstants.SCORE_KEY,
|
||||
checkpoint_score_order="max",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trainer(
|
||||
tmp_path, storage_path_type, checkpoint_config: train.CheckpointConfig
|
||||
):
|
||||
"""Same end-to-end test as `test_tuner`, but also includes a
|
||||
`DataParallelTrainer(resume_from_checkpoint)` test at the end.
|
||||
|
||||
{storage_path}/{exp_name}
|
||||
├── experiment_state-2023-07-28_10-00-38.json <- Initial exp state
|
||||
├── basic-variant-state-2023-07-28_10-00-38.json
|
||||
├── experiment_state-2023-07-28_10-01-38.json <- Restored exp state
|
||||
├── basic-variant-state-2023-07-28_10-01-38.json
|
||||
├── trainer.pkl
|
||||
├── tuner.pkl
|
||||
└── DataParallelTrainer_46367_00000_0_...
|
||||
├── events.out.tfevents...
|
||||
├── params.json
|
||||
├── params.pkl
|
||||
├── progress.csv
|
||||
├── result.json
|
||||
├── checkpoint_000000
|
||||
│ ├── checkpoint.pkl <- Shared checkpoint file
|
||||
│ ├── checkpoint_shard-rank=0.pkl <- Worker checkpoint shards
|
||||
│ └── checkpoint_shard-rank=1.pkl
|
||||
├── ...
|
||||
├── artifact-rank=0-iter=0.txt <- Worker artifacts
|
||||
├── artifact-rank=1-iter=0.txt
|
||||
├── ...
|
||||
├── artifact-rank=0-iter=1.txt
|
||||
├── artifact-rank=1-iter=1.txt
|
||||
└── ...
|
||||
"""
|
||||
exp_name = f"trainer_persistence_test-{uuid.uuid4().hex}"
|
||||
no_checkpoint_ranks = [0]
|
||||
|
||||
with _resolve_storage_type(storage_path_type, tmp_path) as (
|
||||
storage_path,
|
||||
storage_filesystem,
|
||||
):
|
||||
run_config = train.RunConfig(
|
||||
storage_path=storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
name=exp_name,
|
||||
verbose=0,
|
||||
checkpoint_config=checkpoint_config,
|
||||
failure_config=train.FailureConfig(max_failures=1),
|
||||
sync_config=train.SyncConfig(sync_artifacts=True),
|
||||
)
|
||||
trainer = DataParallelTrainer(
|
||||
train_fn,
|
||||
train_loop_config={
|
||||
"in_trainer": True,
|
||||
"num_iterations": TestConstants.NUM_ITERATIONS,
|
||||
"fail_iters": [2, 4],
|
||||
# Test that global rank 0 is not required to checkpoint.
|
||||
"no_checkpoint_ranks": no_checkpoint_ranks,
|
||||
},
|
||||
scaling_config=train.ScalingConfig(num_workers=TestConstants.NUM_WORKERS),
|
||||
run_config=run_config,
|
||||
)
|
||||
print("\nStarting initial run.\n")
|
||||
with pytest.raises(TrainingFailedError):
|
||||
result = trainer.fit()
|
||||
|
||||
print("\nStarting manually restored run.\n")
|
||||
restored_trainer = DataParallelTrainer.restore(
|
||||
path=str(URI(run_config.storage_path) / exp_name),
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
result = restored_trainer.fit()
|
||||
|
||||
_resume_from_checkpoint(
|
||||
result.checkpoint,
|
||||
expected_state={"iter": TestConstants.NUM_ITERATIONS - 1},
|
||||
)
|
||||
|
||||
local_inspect_dir, storage_fs_path = _get_local_inspect_dir(
|
||||
root_local_path=tmp_path,
|
||||
storage_path=run_config.storage_path,
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
|
||||
# First, inspect that the result object returns the correct paths.
|
||||
print(result)
|
||||
trial_fs_path = result.path
|
||||
assert trial_fs_path.startswith(storage_fs_path)
|
||||
for checkpoint, _ in result.best_checkpoints:
|
||||
assert checkpoint.path.startswith(trial_fs_path)
|
||||
|
||||
_assert_storage_contents(
|
||||
local_inspect_dir,
|
||||
exp_name,
|
||||
checkpoint_config,
|
||||
trainable_name="DataParallelTrainer",
|
||||
test_trainer=True,
|
||||
no_checkpoint_ranks=no_checkpoint_ranks,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,154 @@
|
||||
import pyarrow
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.air._internal.uri_utils import URI
|
||||
from ray.air.constants import EXPR_RESULT_FILE
|
||||
from ray.train import CheckpointConfig, Result, RunConfig, ScalingConfig
|
||||
from ray.train.base_trainer import TrainingFailedError
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.tune import TuneConfig, Tuner
|
||||
|
||||
_PARAM_SPACE = {"a": 1, "b": 2}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def build_dummy_trainer(configs):
|
||||
def worker_loop(_config):
|
||||
for i in range(configs["NUM_ITERATIONS"]):
|
||||
# Do some random reports in between checkpoints.
|
||||
train.report({"metric_a": -100, "metric_b": -100})
|
||||
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
with create_dict_checkpoint({"iter": i}) as checkpoint:
|
||||
train.report(
|
||||
metrics={"metric_a": i, "metric_b": -i},
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
else:
|
||||
train.report(metrics={"metric_a": i, "metric_b": -i})
|
||||
raise RuntimeError()
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=worker_loop,
|
||||
train_loop_config=_PARAM_SPACE,
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=False),
|
||||
run_config=RunConfig(
|
||||
name=configs["EXP_NAME"],
|
||||
storage_path=configs["STORAGE_PATH"],
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=configs["NUM_CHECKPOINTS"],
|
||||
checkpoint_score_attribute="metric_a",
|
||||
checkpoint_score_order="max",
|
||||
),
|
||||
),
|
||||
)
|
||||
return trainer
|
||||
|
||||
|
||||
def build_dummy_tuner(configs):
|
||||
return Tuner(
|
||||
build_dummy_trainer(configs),
|
||||
param_space={"train_loop_config": _PARAM_SPACE},
|
||||
tune_config=TuneConfig(num_samples=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("storage", ["local", "remote"])
|
||||
@pytest.mark.parametrize("mode", ["trainer", "tuner"])
|
||||
def test_result_restore(ray_start_4_cpus, tmpdir, mock_s3_bucket_uri, storage, mode):
|
||||
NUM_ITERATIONS = 5
|
||||
NUM_CHECKPOINTS = 3
|
||||
if storage == "local":
|
||||
storage_path = str(tmpdir)
|
||||
elif storage == "remote":
|
||||
storage_path = str(URI(mock_s3_bucket_uri))
|
||||
|
||||
exp_name = "test_result_restore"
|
||||
|
||||
configs = {
|
||||
"EXP_NAME": exp_name,
|
||||
"STORAGE_PATH": storage_path,
|
||||
"NUM_ITERATIONS": NUM_ITERATIONS,
|
||||
"NUM_CHECKPOINTS": NUM_CHECKPOINTS,
|
||||
}
|
||||
|
||||
if mode == "trainer":
|
||||
trainer = build_dummy_trainer(configs)
|
||||
with pytest.raises(TrainingFailedError):
|
||||
trainer.fit()
|
||||
elif mode == "tuner":
|
||||
tuner = build_dummy_tuner(configs)
|
||||
tuner.fit()
|
||||
|
||||
# Find the trial directory to restore
|
||||
exp_dir = str(URI(storage_path) / exp_name)
|
||||
fs, fs_exp_dir = pyarrow.fs.FileSystem.from_uri(exp_dir)
|
||||
for item in fs.get_file_info(pyarrow.fs.FileSelector(fs_exp_dir)):
|
||||
if item.type == pyarrow.fs.FileType.Directory and item.base_name.startswith(
|
||||
"TorchTrainer"
|
||||
):
|
||||
trial_dir = str(URI(exp_dir) / item.base_name)
|
||||
break
|
||||
|
||||
# [1] Restore from path
|
||||
result = Result.from_path(trial_dir)
|
||||
|
||||
# Check if we restored all checkpoints
|
||||
assert result.checkpoint
|
||||
assert len(result.best_checkpoints) == NUM_CHECKPOINTS
|
||||
|
||||
"""
|
||||
Top-3 checkpoints with metrics:
|
||||
|
||||
| iter | metric_a metric_b
|
||||
checkpoint_000004 4 4 -4
|
||||
checkpoint_000003 3 3 -3
|
||||
checkpoint_000002 2 2 -2
|
||||
"""
|
||||
# Check if the checkpoints bounded with correct metrics
|
||||
best_ckpt_a = result.get_best_checkpoint(metric="metric_a", mode="max")
|
||||
assert load_dict_checkpoint(best_ckpt_a)["iter"] == NUM_ITERATIONS - 1
|
||||
|
||||
best_ckpt_b = result.get_best_checkpoint(metric="metric_b", mode="max")
|
||||
assert load_dict_checkpoint(best_ckpt_b)["iter"] == NUM_ITERATIONS - NUM_CHECKPOINTS
|
||||
|
||||
with pytest.raises(RuntimeError, match="Invalid metric name.*"):
|
||||
result.get_best_checkpoint(metric="invalid_metric", mode="max")
|
||||
|
||||
# Check if we properly restored errors
|
||||
assert isinstance(result.error, RuntimeError)
|
||||
|
||||
# Check that the config is properly formatted in the result metrics
|
||||
assert result.metrics.get("config") == {"train_loop_config": _PARAM_SPACE}
|
||||
|
||||
# [2] Restore from path without result.json
|
||||
fs.delete_file((URI(trial_dir) / EXPR_RESULT_FILE).path)
|
||||
result = Result.from_path(trial_dir)
|
||||
|
||||
# Do the same checks as above
|
||||
assert result.checkpoint
|
||||
assert len(result.best_checkpoints) == NUM_CHECKPOINTS
|
||||
|
||||
best_ckpt_a = result.get_best_checkpoint(metric="metric_a", mode="max")
|
||||
assert load_dict_checkpoint(best_ckpt_a)["iter"] == NUM_ITERATIONS - 1
|
||||
|
||||
best_ckpt_b = result.get_best_checkpoint(metric="metric_b", mode="max")
|
||||
assert load_dict_checkpoint(best_ckpt_b)["iter"] == NUM_ITERATIONS - NUM_CHECKPOINTS
|
||||
|
||||
assert isinstance(result.error, RuntimeError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,437 @@
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air._internal.util import StartTraceback
|
||||
from ray.air.constants import SESSION_MISUSE_LOG_ONCE_KEY
|
||||
from ray.air.session import (
|
||||
get_checkpoint,
|
||||
get_dataset_shard,
|
||||
get_local_rank,
|
||||
get_world_rank,
|
||||
get_world_size,
|
||||
report,
|
||||
)
|
||||
from ray.train._internal.accelerator import Accelerator
|
||||
from ray.train._internal.session import (
|
||||
_TrainingResult,
|
||||
get_accelerator,
|
||||
get_session,
|
||||
init_session,
|
||||
set_accelerator,
|
||||
shutdown_session,
|
||||
)
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train.error import SessionMisuseError
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
|
||||
storage = StorageContext(
|
||||
storage_path=tempfile.mkdtemp(),
|
||||
experiment_dir_name="exp_name",
|
||||
trial_dir_name="trial_name",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def ray_start_4_cpus():
|
||||
ray.init(num_cpus=4)
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def session():
|
||||
def f():
|
||||
return 1
|
||||
|
||||
init_session(
|
||||
training_func=f,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
yield get_session()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shutdown():
|
||||
if get_session():
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_init_fail(session):
|
||||
with pytest.raises(ValueError):
|
||||
init_session(lambda: 1, 0)
|
||||
|
||||
|
||||
def test_shutdown(session):
|
||||
shutdown_session()
|
||||
assert not get_session()
|
||||
|
||||
|
||||
def test_world_rank(session):
|
||||
assert get_world_rank() == 0
|
||||
shutdown_session()
|
||||
# Make sure default to 0.
|
||||
assert get_world_rank() == 0
|
||||
|
||||
|
||||
def test_local_rank(session):
|
||||
assert get_local_rank() == 0
|
||||
shutdown_session()
|
||||
# Make sure default to 0.
|
||||
assert get_local_rank() == 0
|
||||
|
||||
|
||||
def test_world_size(session):
|
||||
assert get_world_size() == 1
|
||||
shutdown_session()
|
||||
# Make sure default to 1.
|
||||
assert get_world_size() == 1
|
||||
|
||||
|
||||
def test_train(session):
|
||||
session.start()
|
||||
session.finish()
|
||||
|
||||
|
||||
def test_get_dataset_shard():
|
||||
dataset = ray.data.from_items([1, 2, 3])
|
||||
init_session(
|
||||
training_func=lambda: 1,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
dataset_shard=dataset,
|
||||
storage=storage,
|
||||
)
|
||||
assert get_dataset_shard() == dataset
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_report():
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
report(dict(loss=i))
|
||||
|
||||
init_session(
|
||||
training_func=train_func,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
assert session.get_next().metrics["loss"] == 0
|
||||
assert session.get_next().metrics["loss"] == 1
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_report_fail():
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
report(i)
|
||||
return 1
|
||||
|
||||
init_session(
|
||||
training_func=train_func,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
with pytest.raises(StartTraceback):
|
||||
session.get_next()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_report_after_finish(session):
|
||||
session.start()
|
||||
session.pause_reporting()
|
||||
session.finish()
|
||||
for _ in range(2):
|
||||
report(dict(loss=1))
|
||||
assert session.get_next() is None
|
||||
shutdown_session()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"block,put_result_queue,put_actor_queue",
|
||||
[
|
||||
(False, False, False),
|
||||
(False, False, True),
|
||||
(False, True, False),
|
||||
(True, False, False),
|
||||
(True, False, True),
|
||||
(True, True, False),
|
||||
],
|
||||
)
|
||||
def test_get_result_from_queues(session, block, put_result_queue, put_actor_queue):
|
||||
"""Verify that we get the expected _TrainingResult from each result queue.
|
||||
|
||||
`block` describes whether we wait for a result or return after a timeout.
|
||||
This argument should have no impact on this unit test.
|
||||
`put_result_queue` and `put_actor_queue` are mutually exclusive and describe
|
||||
which queue has results to process. The returned _TrainingResult should be
|
||||
from the expected queue.
|
||||
"""
|
||||
result_queue_training_result = _TrainingResult(
|
||||
checkpoint=None,
|
||||
metrics={"result_queue_metric_key": "result_queue_metric_value"},
|
||||
)
|
||||
if put_result_queue:
|
||||
session.result_queue.put(result_queue_training_result, block=True)
|
||||
inter_actor_result = {"inter_actor_metric_key": "inter_actor_metric_value"}
|
||||
if put_actor_queue:
|
||||
session._get_or_create_inter_actor_queue().put(inter_actor_result, block=True)
|
||||
result = session._get_result_from_queues(block=block)
|
||||
if put_result_queue:
|
||||
assert result == result_queue_training_result
|
||||
elif put_actor_queue:
|
||||
assert (
|
||||
result.metrics["inter_actor_metric_key"]
|
||||
== inter_actor_result["inter_actor_metric_key"]
|
||||
)
|
||||
else:
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_start(session):
|
||||
with pytest.raises(RuntimeError):
|
||||
session.get_next()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_checkpoint():
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint(dict(epoch=i)) as checkpoint:
|
||||
report({}, checkpoint=checkpoint)
|
||||
|
||||
def validate_zero(expected):
|
||||
next = session.get_next()
|
||||
assert next is not None and next.checkpoint is not None
|
||||
assert load_dict_checkpoint(next.checkpoint)["epoch"] == expected
|
||||
|
||||
init_session(
|
||||
training_func=train_func,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
validate_zero(0)
|
||||
validate_zero(1)
|
||||
session.finish()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_load_checkpoint_after_save():
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint(dict(epoch=i)) as checkpoint:
|
||||
report(dict(epoch=i), checkpoint=checkpoint)
|
||||
checkpoint = get_checkpoint()
|
||||
assert load_dict_checkpoint(checkpoint)["epoch"] == i
|
||||
|
||||
init_session(
|
||||
training_func=train_func,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
for i in range(2):
|
||||
session.get_next()
|
||||
session.finish()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def test_locking():
|
||||
"""Tests that report pauses training until fetch_next or finish."""
|
||||
|
||||
def train_1():
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
|
||||
init_session(
|
||||
training_func=train_1,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
session.start()
|
||||
shutdown_session()
|
||||
|
||||
def train_2():
|
||||
for i in range(2):
|
||||
report(dict(loss=i))
|
||||
train_1()
|
||||
|
||||
init_session(
|
||||
training_func=train_2,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
time.sleep(3)
|
||||
|
||||
session.pause_reporting()
|
||||
# Releases session.continue_lock to resume the training thread.
|
||||
session.get_next()
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
session.get_next()
|
||||
session.finish()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
def reset_log_once_with_str(str_to_append=None):
|
||||
key = SESSION_MISUSE_LOG_ONCE_KEY
|
||||
if str_to_append:
|
||||
key += f"-{str_to_append}"
|
||||
ray.util.debug.reset_log_once(key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fn", [get_checkpoint, get_dataset_shard])
|
||||
def test_warn(fn):
|
||||
"""Checks if calling session functions outside of session raises warning."""
|
||||
|
||||
with warnings.catch_warnings(record=True) as record:
|
||||
warnings.simplefilter("always")
|
||||
# Ignore Deprecation warnings.
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
assert not fn()
|
||||
|
||||
assert fn.__name__ in record[0].message.args[0]
|
||||
|
||||
reset_log_once_with_str(fn.__name__)
|
||||
|
||||
|
||||
def test_warn_report():
|
||||
"""Checks if calling session.report function outside of session raises warning."""
|
||||
|
||||
fn = report
|
||||
|
||||
with warnings.catch_warnings(record=True) as record:
|
||||
warnings.simplefilter("always")
|
||||
# Ignore Deprecation warnings.
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
assert not fn(dict())
|
||||
|
||||
assert fn.__name__ in record[0].message.args[0]
|
||||
|
||||
reset_log_once_with_str(fn.__name__)
|
||||
|
||||
|
||||
def test_warn_once():
|
||||
"""Checks if session misuse warning is only shown once per function."""
|
||||
|
||||
with warnings.catch_warnings(record=True) as record:
|
||||
# Ignore Deprecation warnings.
|
||||
warnings.simplefilter("always")
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
assert not get_checkpoint()
|
||||
assert not get_checkpoint()
|
||||
assert not report(dict(x=2))
|
||||
assert not report(dict(x=2))
|
||||
assert not get_dataset_shard()
|
||||
assert not get_dataset_shard()
|
||||
|
||||
# Should only warn once.
|
||||
assert len(record) == 3
|
||||
|
||||
|
||||
class FakeAccelerator(Accelerator):
|
||||
pass
|
||||
|
||||
|
||||
def test_set_and_get_accelerator(session):
|
||||
accelerator = FakeAccelerator()
|
||||
set_accelerator(accelerator)
|
||||
assert get_accelerator(FakeAccelerator) is accelerator
|
||||
|
||||
|
||||
def test_get_accelerator_constructs_default_accelerator(session):
|
||||
assert isinstance(get_accelerator(FakeAccelerator), FakeAccelerator)
|
||||
|
||||
|
||||
def test_get_accelerator_raises_error_outside_session():
|
||||
with pytest.raises(SessionMisuseError):
|
||||
get_accelerator(FakeAccelerator)
|
||||
|
||||
|
||||
def test_set_accelerator_raises_error_if_accelerator_already_set(session):
|
||||
accelerator1, accelerator2 = FakeAccelerator(), FakeAccelerator()
|
||||
set_accelerator(accelerator1)
|
||||
with pytest.raises(RuntimeError):
|
||||
set_accelerator(accelerator2)
|
||||
|
||||
|
||||
def test_set_accelerator_raises_error_outside_session():
|
||||
accelerator = FakeAccelerator()
|
||||
with pytest.raises(SessionMisuseError):
|
||||
set_accelerator(accelerator)
|
||||
|
||||
|
||||
def test_application_error_raised():
|
||||
def f():
|
||||
raise ValueError
|
||||
|
||||
init_session(
|
||||
training_func=f,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
local_world_size=1,
|
||||
world_size=1,
|
||||
storage=storage,
|
||||
)
|
||||
session = get_session()
|
||||
session.start()
|
||||
with pytest.raises(StartTraceback):
|
||||
session.get_next()
|
||||
shutdown_session()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,351 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train._internal.state.schema import (
|
||||
ActorStatusEnum,
|
||||
RunStatusEnum,
|
||||
TrainDatasetInfo,
|
||||
TrainRunInfo,
|
||||
TrainWorkerInfo,
|
||||
)
|
||||
from ray.train._internal.state.state_actor import (
|
||||
TRAIN_STATE_ACTOR_NAME,
|
||||
TRAIN_STATE_ACTOR_NAMESPACE,
|
||||
get_or_create_state_actor,
|
||||
)
|
||||
from ray.train._internal.state.state_manager import TrainRunStateManager
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_gpu_cluster():
|
||||
cluster = Cluster()
|
||||
cluster.add_node(num_gpus=8, num_cpus=9)
|
||||
|
||||
ray.shutdown()
|
||||
ray.init(
|
||||
address=cluster.address,
|
||||
runtime_env={"env_vars": {"RAY_TRAIN_ENABLE_STATE_TRACKING": "1"}},
|
||||
ignore_reinit_error=True,
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
RUN_INFO_JSON_SAMPLE = """{
|
||||
"name": "default_run",
|
||||
"id": "ad5256bc64c04c83833a8b006f531799",
|
||||
"job_id": "0000000001",
|
||||
"controller_actor_id": "3abd1972a19148d78acc78dd9414736e",
|
||||
"start_time_ms": 1717448423000,
|
||||
"run_status": "RUNNING",
|
||||
"status_detail": "",
|
||||
"end_time_ms": null,
|
||||
"resources": [{"CPU": 1}, {"CPU": 1}],
|
||||
"workers": [
|
||||
{
|
||||
"actor_id": "3d86c25634a71832dac32c8802000000",
|
||||
"world_rank": 0,
|
||||
"local_rank": 0,
|
||||
"node_rank": 0,
|
||||
"node_id": "b1e6cbed8533ae2def4e7e7ced9d19858ceb1ed8ab9ba81ab9c07825",
|
||||
"node_ip": "10.0.208.100",
|
||||
"pid": 76071,
|
||||
"gpu_ids": [0],
|
||||
"status": "ALIVE",
|
||||
"resources": {"CPU": 1}
|
||||
},
|
||||
{
|
||||
"actor_id": "8f162dd8365346d1b5c98ebd7338c4f9",
|
||||
"world_rank": 1,
|
||||
"local_rank": 1,
|
||||
"node_rank": 0,
|
||||
"node_id": "b1e6cbed8533ae2def4e7e7ced9d19858ceb1ed8ab9ba81ab9c07825",
|
||||
"node_ip": "10.0.208.100",
|
||||
"pid": 76072,
|
||||
"gpu_ids": [1],
|
||||
"status": "ALIVE",
|
||||
"resources": {"CPU": 1}
|
||||
}
|
||||
],
|
||||
"datasets": [
|
||||
{
|
||||
"name": "train",
|
||||
"dataset_name": "train_dataset",
|
||||
"dataset_uuid": "1"
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
def _get_run_info_sample(run_id=None, run_name=None) -> TrainRunInfo:
|
||||
dataset_info = TrainDatasetInfo(
|
||||
name="train", dataset_name="train_dataset", dataset_uuid="1"
|
||||
)
|
||||
|
||||
worker_info_0 = TrainWorkerInfo(
|
||||
actor_id="3d86c25634a71832dac32c8802000000",
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
node_id="b1e6cbed8533ae2def4e7e7ced9d19858ceb1ed8ab9ba81ab9c07825",
|
||||
node_ip="10.0.208.100",
|
||||
pid=76071,
|
||||
gpu_ids=[0],
|
||||
status=ActorStatusEnum.ALIVE,
|
||||
resources={"CPU": 1},
|
||||
)
|
||||
|
||||
worker_info_1 = TrainWorkerInfo(
|
||||
actor_id="8f162dd8365346d1b5c98ebd7338c4f9",
|
||||
world_rank=1,
|
||||
local_rank=1,
|
||||
node_rank=0,
|
||||
node_id="b1e6cbed8533ae2def4e7e7ced9d19858ceb1ed8ab9ba81ab9c07825",
|
||||
node_ip="10.0.208.100",
|
||||
pid=76072,
|
||||
gpu_ids=[1],
|
||||
status=ActorStatusEnum.ALIVE,
|
||||
resources={"CPU": 1},
|
||||
)
|
||||
|
||||
run_info = TrainRunInfo(
|
||||
name=run_name if run_name else "default_run",
|
||||
id=run_id if run_id else "ad5256bc64c04c83833a8b006f531799",
|
||||
job_id="0000000001",
|
||||
controller_actor_id="3abd1972a19148d78acc78dd9414736e",
|
||||
workers=[worker_info_0, worker_info_1],
|
||||
datasets=[dataset_info],
|
||||
start_time_ms=1717448423000,
|
||||
run_status=RunStatusEnum.RUNNING,
|
||||
status_detail="",
|
||||
resources=[{"CPU": 1}, {"CPU": 1}],
|
||||
)
|
||||
return run_info
|
||||
|
||||
|
||||
def test_schema_equivalance():
|
||||
json_sample = RUN_INFO_JSON_SAMPLE
|
||||
dict_sample = json.loads(RUN_INFO_JSON_SAMPLE)
|
||||
|
||||
run_info_from_json = TrainRunInfo.parse_raw(json_sample)
|
||||
run_info_from_obj = TrainRunInfo.parse_obj(dict_sample)
|
||||
|
||||
# Test serialization equivalence
|
||||
assert run_info_from_json == run_info_from_obj
|
||||
|
||||
# Test dict deserialization equivalence
|
||||
assert run_info_from_json.dict() == dict_sample
|
||||
|
||||
# Test json deserialization equivalence
|
||||
assert json.loads(run_info_from_json.json()) == json.loads(json_sample)
|
||||
|
||||
# Test constructors equivalence
|
||||
assert _get_run_info_sample() == run_info_from_json
|
||||
|
||||
|
||||
def test_state_actor_api(ray_start_4_cpus):
|
||||
state_actor = get_or_create_state_actor()
|
||||
named_actors = ray.util.list_named_actors(all_namespaces=True)
|
||||
assert {
|
||||
"name": TRAIN_STATE_ACTOR_NAME,
|
||||
"namespace": TRAIN_STATE_ACTOR_NAMESPACE,
|
||||
} in named_actors
|
||||
|
||||
# Concurrently register 100 runs
|
||||
num_runs = 100
|
||||
info_list = [_get_run_info_sample(run_id=str(i)) for i in range(num_runs)]
|
||||
ray.get([state_actor.register_train_run.remote(run) for run in info_list])
|
||||
|
||||
# Test get all runs
|
||||
train_runs = ray.get(state_actor.get_all_train_runs.remote())
|
||||
assert len(train_runs) == num_runs
|
||||
|
||||
# Test get a single run by run_id
|
||||
for i in range(num_runs):
|
||||
run_info = ray.get(state_actor.get_train_run.remote(run_id=str(i)))
|
||||
assert run_info == info_list[i]
|
||||
|
||||
|
||||
def test_state_manager(ray_start_gpu_cluster):
|
||||
worker_group = WorkerGroup(num_workers=4, resources_per_worker={"GPU": 1})
|
||||
|
||||
# No errors raised if TrainStateActor is not started
|
||||
state_manager = TrainRunStateManager(state_actor=None)
|
||||
state_manager.register_train_run(
|
||||
run_id="run_id",
|
||||
run_name="run_name",
|
||||
job_id="0000000001",
|
||||
controller_actor_id="3abd1972a19148d78acc78dd9414736e",
|
||||
datasets={},
|
||||
worker_group=worker_group,
|
||||
start_time_ms=int(time.time() * 1000),
|
||||
run_status=RunStatusEnum.RUNNING,
|
||||
resources=[{"CPU": 1}, {"CPU": 1}],
|
||||
)
|
||||
|
||||
# Register 100 runs with 10 TrainRunStateManagers
|
||||
state_actor = get_or_create_state_actor()
|
||||
for i in range(10):
|
||||
state_manager = TrainRunStateManager(state_actor=state_actor)
|
||||
for j in range(10):
|
||||
run_id = i * 10 + j
|
||||
state_manager.register_train_run(
|
||||
run_id=str(run_id),
|
||||
run_name="run_name",
|
||||
job_id="0000000001",
|
||||
controller_actor_id="3abd1972a19148d78acc78dd9414736e",
|
||||
datasets={
|
||||
"train": ray.data.from_items(list(range(4))),
|
||||
"eval": ray.data.from_items(list(range(4))),
|
||||
},
|
||||
worker_group=worker_group,
|
||||
start_time_ms=int(time.time() * 1000),
|
||||
run_status=RunStatusEnum.RUNNING,
|
||||
resources=[{"CPU": 1}, {"CPU": 1}],
|
||||
)
|
||||
|
||||
runs = ray.get(state_actor.get_all_train_runs.remote())
|
||||
assert len(runs) == 100
|
||||
|
||||
for i in range(100):
|
||||
run_id = str(i)
|
||||
run_info = ray.get(state_actor.get_train_run.remote(run_id=run_id))
|
||||
assert run_info and run_info.id == run_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gpus_per_worker", [0, 1, 2])
|
||||
def test_track_e2e_training(ray_start_gpu_cluster, gpus_per_worker):
|
||||
os.environ["RAY_TRAIN_ENABLE_STATE_TRACKING"] = "1"
|
||||
num_workers = 4
|
||||
run_name = "test"
|
||||
datasets = {
|
||||
"train": ray.data.from_items(list(range(4))),
|
||||
"eval": ray.data.from_items(list(range(4))),
|
||||
}
|
||||
|
||||
if gpus_per_worker == 0:
|
||||
use_gpu = False
|
||||
resources_per_worker = {"CPU": 1}
|
||||
else:
|
||||
use_gpu = True
|
||||
resources_per_worker = {"GPU": gpus_per_worker}
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=lambda: None,
|
||||
run_config=RunConfig(name=run_name),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
use_gpu=use_gpu,
|
||||
resources_per_worker=resources_per_worker,
|
||||
),
|
||||
datasets=datasets,
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
|
||||
state_actor = ray.get_actor(
|
||||
name=TRAIN_STATE_ACTOR_NAME, namespace=TRAIN_STATE_ACTOR_NAMESPACE
|
||||
)
|
||||
|
||||
runs = ray.get(state_actor.get_all_train_runs.remote())
|
||||
run_id = next(iter(runs.keys()))
|
||||
run = next(iter(runs.values()))
|
||||
|
||||
# Check Run Info
|
||||
assert run.id == run_id
|
||||
assert run.name == run_name
|
||||
assert len(run.workers) == num_workers
|
||||
assert run.controller_actor_id and run.job_id
|
||||
|
||||
world_ranks = [worker.world_rank for worker in run.workers]
|
||||
local_ranks = [worker.local_rank for worker in run.workers]
|
||||
node_ranks = [worker.node_rank for worker in run.workers]
|
||||
|
||||
# Ensure that the workers are sorted by global rank
|
||||
assert world_ranks == [0, 1, 2, 3]
|
||||
assert local_ranks == [0, 1, 2, 3]
|
||||
assert node_ranks == [0, 0, 0, 0]
|
||||
|
||||
# Check GPU ids
|
||||
gpu_ids = [worker.gpu_ids for worker in run.workers]
|
||||
if gpus_per_worker == 0:
|
||||
assert gpu_ids == [[], [], [], []]
|
||||
elif gpus_per_worker == 1:
|
||||
assert gpu_ids == [[0], [1], [2], [3]]
|
||||
elif gpus_per_worker == 2:
|
||||
flat_gpu_ids = set()
|
||||
for ids in gpu_ids:
|
||||
flat_gpu_ids.update(ids)
|
||||
assert flat_gpu_ids == set(range(8))
|
||||
|
||||
# Check Datasets
|
||||
for dataset_info in run.datasets:
|
||||
dataset = datasets[dataset_info.name]
|
||||
# DataConfig will automatically set the dataset_name to the key of the dataset dict.
|
||||
assert dataset_info.dataset_name == dataset_info.name
|
||||
assert dataset_info.dataset_uuid == dataset._uuid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raise_error", [True, False])
|
||||
def test_train_run_status(ray_start_gpu_cluster, raise_error):
|
||||
os.environ["RAY_TRAIN_ENABLE_STATE_TRACKING"] = "1"
|
||||
|
||||
def get_train_run():
|
||||
state_actor = ray.get_actor(
|
||||
name=TRAIN_STATE_ACTOR_NAME, namespace=TRAIN_STATE_ACTOR_NAMESPACE
|
||||
)
|
||||
runs = ray.get(state_actor.get_all_train_runs.remote())
|
||||
return next(iter(runs.values()))
|
||||
|
||||
def check_run_status(expected_status):
|
||||
run = get_train_run()
|
||||
assert run.run_status == expected_status
|
||||
|
||||
def check_run_error(failed_rank, error_message):
|
||||
run = get_train_run()
|
||||
assert run.status_detail
|
||||
assert f"Rank {failed_rank} worker raised an error" in run.status_detail
|
||||
assert error_message in run.status_detail
|
||||
|
||||
failed_rank = 0
|
||||
error_message = "User Application Error"
|
||||
|
||||
def train_func():
|
||||
check_run_status(expected_status=RunStatusEnum.RUNNING)
|
||||
if raise_error and ray.train.get_context().get_world_rank() == failed_rank:
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=False),
|
||||
)
|
||||
|
||||
try:
|
||||
trainer.fit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if raise_error:
|
||||
check_run_status(expected_status=RunStatusEnum.ERRORED)
|
||||
check_run_error(failed_rank=failed_rank, error_message=error_message)
|
||||
else:
|
||||
check_run_status(expected_status=RunStatusEnum.FINISHED)
|
||||
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,126 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.core.generated.export_train_state_pb2 import (
|
||||
ExportTrainRunAttemptEventData as ProtoTrainRunAttempt,
|
||||
ExportTrainRunEventData as ProtoTrainRun,
|
||||
)
|
||||
from ray.train._internal.state.export import (
|
||||
train_run_info_to_proto_attempt,
|
||||
train_run_info_to_proto_run,
|
||||
)
|
||||
from ray.train._internal.state.schema import (
|
||||
ActorStatusEnum,
|
||||
RunStatusEnum,
|
||||
TrainRunInfo,
|
||||
TrainWorkerInfo,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_train_run_info(run_status=RunStatusEnum.RUNNING):
|
||||
"""Create a minimal mock TrainRunInfo."""
|
||||
worker = TrainWorkerInfo(
|
||||
actor_id=uuid.uuid4().hex,
|
||||
world_rank=0,
|
||||
local_rank=0,
|
||||
node_rank=0,
|
||||
node_id=uuid.uuid4().hex,
|
||||
node_ip="127.0.0.1",
|
||||
pid=1234,
|
||||
gpu_ids=[0],
|
||||
status=ActorStatusEnum.ALIVE,
|
||||
resources={"CPU": 1},
|
||||
)
|
||||
|
||||
return TrainRunInfo(
|
||||
name="test_run",
|
||||
id=uuid.uuid4().hex,
|
||||
job_id=uuid.uuid4().hex,
|
||||
controller_actor_id=uuid.uuid4().hex,
|
||||
workers=[worker],
|
||||
datasets=[],
|
||||
run_status=run_status,
|
||||
status_detail="Error details" if run_status == RunStatusEnum.ERRORED else "",
|
||||
start_time_ms=1000,
|
||||
end_time_ms=2000
|
||||
if run_status in [RunStatusEnum.FINISHED, RunStatusEnum.ERRORED]
|
||||
else None,
|
||||
resources=[{"CPU": 1}],
|
||||
)
|
||||
|
||||
|
||||
def test_train_run_info_to_proto_run():
|
||||
"""Test that run info is correctly exported to proto run."""
|
||||
run_info = create_mock_train_run_info()
|
||||
proto_run = train_run_info_to_proto_run(run_info)
|
||||
|
||||
assert proto_run.ray_train_version == 1
|
||||
assert proto_run.id == run_info.id
|
||||
assert proto_run.name == run_info.name
|
||||
assert proto_run.job_id == bytes.fromhex(run_info.job_id)
|
||||
assert proto_run.status == ProtoTrainRun.RunStatus.RUNNING
|
||||
|
||||
|
||||
def test_train_run_info_to_proto_attempt():
|
||||
"""Test that run info is correctly exported to proto attempt."""
|
||||
run_info = create_mock_train_run_info()
|
||||
proto_attempt = train_run_info_to_proto_attempt(run_info)
|
||||
|
||||
assert proto_attempt.ray_train_version == 1
|
||||
assert proto_attempt.run_id == run_info.id
|
||||
assert proto_attempt.status == ProtoTrainRunAttempt.RunAttemptStatus.RUNNING
|
||||
|
||||
# Verify worker fields
|
||||
assert len(proto_attempt.workers) == 1
|
||||
proto_worker = proto_attempt.workers[0]
|
||||
worker_info = run_info.workers[0]
|
||||
|
||||
assert proto_worker.world_rank == worker_info.world_rank
|
||||
assert proto_worker.actor_id == bytes.fromhex(worker_info.actor_id)
|
||||
|
||||
|
||||
def test_status_mapping():
|
||||
"""Test that status is correctly mapped between schemas."""
|
||||
status_pairs = [
|
||||
(
|
||||
RunStatusEnum.STARTED,
|
||||
ProtoTrainRun.RunStatus.INITIALIZING,
|
||||
ProtoTrainRunAttempt.RunAttemptStatus.PENDING,
|
||||
),
|
||||
(
|
||||
RunStatusEnum.RUNNING,
|
||||
ProtoTrainRun.RunStatus.RUNNING,
|
||||
ProtoTrainRunAttempt.RunAttemptStatus.RUNNING,
|
||||
),
|
||||
(
|
||||
RunStatusEnum.FINISHED,
|
||||
ProtoTrainRun.RunStatus.FINISHED,
|
||||
ProtoTrainRunAttempt.RunAttemptStatus.FINISHED,
|
||||
),
|
||||
(
|
||||
RunStatusEnum.ERRORED,
|
||||
ProtoTrainRun.RunStatus.ERRORED,
|
||||
ProtoTrainRunAttempt.RunAttemptStatus.ERRORED,
|
||||
),
|
||||
(
|
||||
RunStatusEnum.ABORTED,
|
||||
ProtoTrainRun.RunStatus.ABORTED,
|
||||
ProtoTrainRunAttempt.RunAttemptStatus.ABORTED,
|
||||
),
|
||||
]
|
||||
|
||||
for run_status, expected_run_status, expected_attempt_status in status_pairs:
|
||||
run_info = create_mock_train_run_info(run_status=run_status)
|
||||
|
||||
proto_run = train_run_info_to_proto_run(run_info)
|
||||
assert proto_run.status == expected_run_status
|
||||
|
||||
proto_attempt = train_run_info_to_proto_attempt(run_info)
|
||||
assert proto_attempt.status == expected_attempt_status
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,207 @@
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
import ray.cloudpickle as ray_pickle
|
||||
from ray.train import Checkpoint, SyncConfig
|
||||
from ray.train._internal.storage import (
|
||||
_VALIDATE_STORAGE_MARKER_FILENAME,
|
||||
StorageContext,
|
||||
_list_at_fs_path,
|
||||
)
|
||||
from ray.train.tests.test_new_persistence import _resolve_storage_type
|
||||
|
||||
|
||||
@pytest.fixture(params=["nfs", "cloud", "custom_fs"])
|
||||
def storage(request, tmp_path) -> StorageContext:
|
||||
storage_type = request.param
|
||||
with _resolve_storage_type(storage_type, tmp_path) as (
|
||||
storage_path,
|
||||
storage_filesystem,
|
||||
):
|
||||
yield StorageContext(
|
||||
storage_path=storage_path,
|
||||
experiment_dir_name=f"storage_type={storage_type}-{uuid.uuid4().hex}",
|
||||
storage_filesystem=storage_filesystem,
|
||||
trial_dir_name="trial_name",
|
||||
sync_config=SyncConfig(
|
||||
sync_artifacts=True, sync_artifacts_on_checkpoint=True, sync_period=1000
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def ray_init():
|
||||
# NOTE: This is needed to set the `/tmp/ray/session_*` directory.
|
||||
ray.init()
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_custom_fs_validation(tmp_path):
|
||||
"""Tests that invalid storage_path inputs give reasonable errors when a
|
||||
custom filesystem is used."""
|
||||
exp_name = "test"
|
||||
StorageContext(
|
||||
storage_path=str(tmp_path),
|
||||
experiment_dir_name=exp_name,
|
||||
storage_filesystem=pyarrow.fs.LocalFileSystem(),
|
||||
)
|
||||
|
||||
mock_fs, _ = pyarrow.fs.FileSystem.from_uri("mock://a")
|
||||
with pytest.raises(pyarrow.lib.ArrowInvalid) as excinfo:
|
||||
StorageContext(
|
||||
storage_path="mock:///a",
|
||||
experiment_dir_name=exp_name,
|
||||
storage_filesystem=mock_fs,
|
||||
)
|
||||
print("Custom fs with URI storage path error: ", excinfo.value)
|
||||
|
||||
StorageContext(
|
||||
storage_path="a",
|
||||
experiment_dir_name=exp_name,
|
||||
storage_filesystem=mock_fs,
|
||||
)
|
||||
|
||||
|
||||
def test_storage_path_inputs():
|
||||
"""Tests storage path input edge cases."""
|
||||
exp_name = "test_storage_path"
|
||||
|
||||
# Relative paths don't work
|
||||
with pytest.raises(pyarrow.lib.ArrowInvalid) as excinfo:
|
||||
StorageContext(storage_path="./results", experiment_dir_name=exp_name)
|
||||
assert "URI has empty scheme" in str(excinfo.value)
|
||||
|
||||
with pytest.raises(pyarrow.lib.ArrowInvalid) as excinfo:
|
||||
StorageContext(storage_path="results", experiment_dir_name=exp_name)
|
||||
assert "URI has empty scheme" in str(excinfo.value)
|
||||
|
||||
# Tilde paths sometimes raise... They do not work on the CI machines.
|
||||
try:
|
||||
StorageContext(storage_path="~/ray_results", experiment_dir_name=exp_name)
|
||||
except pyarrow.lib.ArrowInvalid as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
# Paths with lots of extra . .. and /
|
||||
path = os.path.expanduser("~/ray_results")
|
||||
path = os.path.join(path, ".", "..", "ray_results", ".")
|
||||
path = path.replace(os.path.sep, os.path.sep * 2)
|
||||
storage = StorageContext(storage_path=path, experiment_dir_name=exp_name)
|
||||
|
||||
storage.storage_filesystem.create_dir(
|
||||
os.path.join(storage.storage_fs_path, "test_dir")
|
||||
)
|
||||
assert Path("~/ray_results/test_dir").expanduser().exists()
|
||||
|
||||
# Path objects work
|
||||
StorageContext(storage_path=Path(path), experiment_dir_name=exp_name)
|
||||
|
||||
|
||||
def test_storage_validation_marker(storage: StorageContext):
|
||||
# A marker should have been created at initialization
|
||||
storage._check_validation_file()
|
||||
|
||||
# Remove the marker to simulate being on a new node w/o access to the shared storage
|
||||
storage.storage_filesystem.delete_file(
|
||||
os.path.join(storage.experiment_fs_path, _VALIDATE_STORAGE_MARKER_FILENAME)
|
||||
)
|
||||
|
||||
# Simulate passing the storage context around through the object store
|
||||
# The constructor is NOT called again -- so the marker should not be checked here
|
||||
# and we shouldn't raise an error
|
||||
storage = ray_pickle.loads(ray_pickle.dumps(storage))
|
||||
|
||||
# We should raise an error when we try to checkpoint now.
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
storage.persist_current_checkpoint(Checkpoint.from_directory("/tmp/dummy"))
|
||||
assert "Unable to set up cluster storage" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_persist_current_checkpoint(storage: StorageContext, tmp_path):
|
||||
# Uploading a non-existent checkpoint directory should fail
|
||||
with pytest.raises(FileNotFoundError):
|
||||
storage.persist_current_checkpoint(
|
||||
Checkpoint.from_directory("/tmp/nonexistent/checkpoint")
|
||||
)
|
||||
|
||||
# Uploading an empty checkpoint directory
|
||||
(tmp_path / "empty").mkdir()
|
||||
storage.persist_current_checkpoint(Checkpoint.from_directory(tmp_path / "empty"))
|
||||
assert (
|
||||
_list_at_fs_path(storage.storage_filesystem, storage.checkpoint_fs_path) == []
|
||||
)
|
||||
|
||||
# Normal use case: Uploading an checkpoint directory with files
|
||||
(tmp_path / "regular").mkdir()
|
||||
(tmp_path / "regular" / "1.txt").touch()
|
||||
storage.persist_current_checkpoint(Checkpoint.from_directory(tmp_path / "regular"))
|
||||
assert _list_at_fs_path(storage.storage_filesystem, storage.checkpoint_fs_path) == [
|
||||
"1.txt"
|
||||
]
|
||||
|
||||
storage.current_checkpoint_index += 1
|
||||
|
||||
# Persisting a checkpoint that is already at the correct path (for local fs case)
|
||||
if isinstance(storage.storage_filesystem, pyarrow.fs.LocalFileSystem):
|
||||
final_checkpoint_dir = Path(storage.checkpoint_fs_path)
|
||||
final_checkpoint_dir.mkdir(parents=True)
|
||||
(final_checkpoint_dir / "2.txt").touch()
|
||||
storage.persist_current_checkpoint(
|
||||
Checkpoint.from_directory(final_checkpoint_dir)
|
||||
)
|
||||
|
||||
|
||||
def test_persist_artifacts(storage: StorageContext):
|
||||
"""Tests typical `StorageContext.persist_artifacts(force=True/False)` usage."""
|
||||
trial_working_dir = Path(storage.trial_working_directory)
|
||||
trial_working_dir.mkdir(parents=True)
|
||||
trial_working_dir.joinpath("1.txt").touch()
|
||||
|
||||
storage.persist_artifacts()
|
||||
storage.syncer.wait()
|
||||
|
||||
assert sorted(
|
||||
_list_at_fs_path(storage.storage_filesystem, storage.trial_fs_path)
|
||||
) == ["1.txt"]
|
||||
|
||||
trial_working_dir.joinpath("2.txt").touch()
|
||||
|
||||
# A new sync should not be triggered because sync_period is 1000 seconds
|
||||
storage.persist_artifacts()
|
||||
storage.syncer.wait()
|
||||
|
||||
# -> No change in the persisted files
|
||||
assert sorted(
|
||||
_list_at_fs_path(storage.storage_filesystem, storage.trial_fs_path)
|
||||
) == ["1.txt"]
|
||||
|
||||
# This is what happens on `train.report` when a checkpoint is reported
|
||||
# and `sync_artifacts_on_checkpoint=True`
|
||||
storage.persist_artifacts(force=storage.sync_config.sync_artifacts_on_checkpoint)
|
||||
assert sorted(
|
||||
_list_at_fs_path(storage.storage_filesystem, storage.trial_fs_path)
|
||||
) == ["1.txt", "2.txt"]
|
||||
|
||||
|
||||
def test_persist_artifacts_failures(storage: StorageContext):
|
||||
"""Tests `StorageContext.persist_artifacts` edge cases (empty directory)."""
|
||||
# Uploading before the trial directory has been created should fail
|
||||
with pytest.raises(FileNotFoundError):
|
||||
storage.persist_artifacts()
|
||||
if storage.syncer:
|
||||
storage.syncer.wait()
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
storage.persist_artifacts(force=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,50 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
import ray._common.usage.usage_lib as ray_usage_lib
|
||||
from ray._common.test_utils import TelemetryCallsite, check_library_usage_telemetry
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_usage_lib():
|
||||
yield
|
||||
ray.shutdown()
|
||||
ray_usage_lib.reset_global_state()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("callsite", list(TelemetryCallsite))
|
||||
def test_not_used_on_import(reset_usage_lib, callsite: TelemetryCallsite):
|
||||
def _import_ray_train():
|
||||
from ray import train # noqa: F401
|
||||
|
||||
check_library_usage_telemetry(
|
||||
_import_ray_train, callsite=callsite, expected_library_usages=[set(), {"core"}]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("callsite", list(TelemetryCallsite))
|
||||
def test_used_on_train_fit(reset_usage_lib, callsite: TelemetryCallsite):
|
||||
def _call_train_fit():
|
||||
def train_fn():
|
||||
pass
|
||||
|
||||
trainer = DataParallelTrainer(train_fn)
|
||||
trainer.fit()
|
||||
|
||||
check_library_usage_telemetry(
|
||||
_call_train_fit,
|
||||
callsite=callsite,
|
||||
expected_library_usages=[{"train", "tune"}, {"core", "train", "tune"}],
|
||||
expected_extra_usage_tags={
|
||||
"air_entrypoint": "Trainer.fit",
|
||||
"air_storage_configuration": "local",
|
||||
"air_trainer": "Custom",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
@@ -0,0 +1,151 @@
|
||||
import os
|
||||
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import os.path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from numpy import ndarray
|
||||
|
||||
from ray import train
|
||||
from ray.data import Preprocessor
|
||||
from ray.train import ScalingConfig
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
# Tensorflow is not installed for Python 3.12 because of keras compatibility.
|
||||
sys.exit(0)
|
||||
else:
|
||||
import tensorflow as tf
|
||||
|
||||
from ray.train.tensorflow import TensorflowCheckpoint, TensorflowTrainer
|
||||
|
||||
|
||||
class DummyPreprocessor(Preprocessor):
|
||||
def __init__(self, multiplier):
|
||||
self.multiplier = multiplier
|
||||
|
||||
def transform_batch(self, df):
|
||||
return df * self.multiplier
|
||||
|
||||
|
||||
def get_model():
|
||||
return tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.InputLayer(input_shape=()),
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(10),
|
||||
tf.keras.layers.Dense(1),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def compare_weights(w1: List[ndarray], w2: List[ndarray]) -> bool:
|
||||
if not len(w1) == len(w2):
|
||||
return False
|
||||
size = len(w1)
|
||||
for i in range(size):
|
||||
comparison = w1[i] == w2[i]
|
||||
if not comparison.all():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class TestFromModel(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.model = get_model()
|
||||
self.preprocessor = DummyPreprocessor(1)
|
||||
|
||||
def test_from_model(self):
|
||||
checkpoint = TensorflowCheckpoint.from_model(
|
||||
self.model, preprocessor=DummyPreprocessor(1)
|
||||
)
|
||||
loaded_model = checkpoint.get_model()
|
||||
preprocessor = checkpoint.get_preprocessor()
|
||||
|
||||
assert compare_weights(loaded_model.get_weights(), self.model.get_weights())
|
||||
assert preprocessor.multiplier == 1
|
||||
|
||||
def test_from_saved_model(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
model_dir_path = os.path.join(tmp_dir, "my_model")
|
||||
self.model.save(model_dir_path, save_format="tf")
|
||||
checkpoint = TensorflowCheckpoint.from_saved_model(
|
||||
model_dir_path, preprocessor=DummyPreprocessor(1)
|
||||
)
|
||||
loaded_model = checkpoint.get_model()
|
||||
preprocessor = checkpoint.get_preprocessor()
|
||||
assert compare_weights(self.model.get_weights(), loaded_model.get_weights())
|
||||
assert preprocessor.multiplier == 1
|
||||
|
||||
def test_from_h5_model(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
model_file_path = os.path.join(tmp_dir, "my_model.h5")
|
||||
self.model.save(model_file_path)
|
||||
checkpoint = TensorflowCheckpoint.from_h5(
|
||||
model_file_path, preprocessor=DummyPreprocessor(1)
|
||||
)
|
||||
loaded_model = checkpoint.get_model()
|
||||
preprocessor = checkpoint.get_preprocessor()
|
||||
assert compare_weights(self.model.get_weights(), loaded_model.get_weights())
|
||||
assert preprocessor.multiplier == 1
|
||||
|
||||
|
||||
def test_tensorflow_checkpoint_saved_model():
|
||||
# The test passes if the following can run successfully.
|
||||
|
||||
def train_fn():
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.InputLayer(input_shape=()),
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(10),
|
||||
tf.keras.layers.Dense(1),
|
||||
]
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
model.save(tempdir)
|
||||
checkpoint = TensorflowCheckpoint.from_saved_model(tempdir)
|
||||
train.report({"my_metric": 1}, checkpoint=checkpoint)
|
||||
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_fn, scaling_config=ScalingConfig(num_workers=2)
|
||||
)
|
||||
|
||||
assert trainer.fit().checkpoint
|
||||
|
||||
|
||||
def test_tensorflow_checkpoint_h5():
|
||||
# The test passes if the following can run successfully.
|
||||
|
||||
def train_func():
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.InputLayer(input_shape=()),
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(10),
|
||||
tf.keras.layers.Dense(1),
|
||||
]
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
model.save(os.path.join(tempdir, "my_model.h5"))
|
||||
checkpoint = TensorflowCheckpoint.from_h5(
|
||||
os.path.join(tempdir, "my_model.h5")
|
||||
)
|
||||
train.report({"my_metric": 1}, checkpoint=checkpoint)
|
||||
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=ScalingConfig(num_workers=2)
|
||||
)
|
||||
|
||||
assert trainer.fit().checkpoint
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,125 @@
|
||||
import os
|
||||
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.data.preprocessors import Concatenator
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
# Tensorflow is not installed for Python 3.12 because of keras compatibility.
|
||||
sys.exit(0)
|
||||
else:
|
||||
from ray.train.examples.tf.tensorflow_regression_example import (
|
||||
train_func as tensorflow_linear_train_func,
|
||||
)
|
||||
from ray.train.tensorflow import TensorflowCheckpoint, TensorflowTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
)
|
||||
|
||||
return model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 2])
|
||||
def test_tensorflow_linear(ray_start_4_cpus, num_workers):
|
||||
"""Also tests air Keras callback."""
|
||||
epochs = 3
|
||||
|
||||
def train_func(config):
|
||||
result = tensorflow_linear_train_func(config)
|
||||
assert len(result) == epochs
|
||||
assert result[-1]["loss"] < result[0]["loss"]
|
||||
|
||||
train_loop_config = {
|
||||
"lr": 1e-3,
|
||||
"batch_size": 32,
|
||||
"epochs": epochs,
|
||||
}
|
||||
scaling_config = ScalingConfig(num_workers=num_workers)
|
||||
dataset = ray.data.read_csv("s3://anonymous@air-example-data/regression.csv")
|
||||
columns_to_concatenate = [f"x{i:03}" for i in range(100)]
|
||||
preprocessor = Concatenator(columns=columns_to_concatenate, output_column_name="x")
|
||||
dataset = preprocessor.transform(dataset)
|
||||
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=scaling_config,
|
||||
datasets={TRAIN_DATASET_KEY: dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert result.checkpoint
|
||||
|
||||
|
||||
def test_report_and_load_using_ml_session(ray_start_4_cpus):
|
||||
def train_func():
|
||||
checkpoint = train.get_checkpoint()
|
||||
if checkpoint:
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
import tensorflow as tf
|
||||
|
||||
model = tf.keras.models.load_model(checkpoint_dir)
|
||||
else:
|
||||
model = build_model()
|
||||
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
model.save(tmp_dir)
|
||||
train.report(
|
||||
metrics={"iter": 1},
|
||||
checkpoint=TensorflowCheckpoint.from_saved_model(tmp_dir),
|
||||
)
|
||||
else:
|
||||
train.report(metrics={"iter": 1})
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
checkpoint = result.checkpoint
|
||||
|
||||
trainer2 = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
resume_from_checkpoint=checkpoint,
|
||||
)
|
||||
result = trainer2.fit()
|
||||
checkpoint = result.checkpoint
|
||||
with checkpoint.as_directory() as ckpt_dir:
|
||||
assert os.path.exists(os.path.join(ckpt_dir, "saved_model.pb"))
|
||||
assert result.metrics["iter"] == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,225 @@
|
||||
import os
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from accelerate import Accelerator
|
||||
|
||||
import ray
|
||||
import ray.train as train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import LinearDataset
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
DEEPSPEED_CONFIG = {
|
||||
"fp16": {
|
||||
"enabled": "auto",
|
||||
"loss_scale": 0,
|
||||
"loss_scale_window": 1000,
|
||||
"initial_scale_power": 16,
|
||||
"hysteresis": 2,
|
||||
"min_loss_scale": 1,
|
||||
},
|
||||
"bf16": {"enabled": "auto"},
|
||||
"optimizer": {
|
||||
"type": "AdamW",
|
||||
"params": {
|
||||
"lr": "auto",
|
||||
"weight_decay": "auto",
|
||||
"torch_adam": True,
|
||||
"adam_w_mode": True,
|
||||
},
|
||||
},
|
||||
"zero_optimization": {
|
||||
"stage": 2,
|
||||
"offload_optimizer": {"device": "cpu", "pin_memory": True},
|
||||
"allgather_partitions": True,
|
||||
"allgather_bucket_size": 2e8,
|
||||
"overlap_comm": True,
|
||||
"reduce_scatter": True,
|
||||
"contiguous_gradients": True,
|
||||
},
|
||||
"gradient_accumulation_steps": 1,
|
||||
"gradient_clipping": "auto",
|
||||
"steps_per_print": 2000,
|
||||
"train_batch_size": "auto",
|
||||
"train_micro_batch_size_per_gpu": "auto",
|
||||
"wall_clock_breakdown": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def linear_train_func(accelerator: Accelerator, config):
|
||||
from accelerate.utils import DummyOptim
|
||||
from deepspeed.ops.adam import DeepSpeedCPUAdam
|
||||
|
||||
data_size = config.get("data_size", 1000)
|
||||
val_size = config.get("val_size", 400)
|
||||
batch_size = config.get("batch_size", 32)
|
||||
hidden_size = config.get("hidden_size", 1)
|
||||
lr = config.get("lr", 1e-2)
|
||||
epochs = config.get("epochs", 3)
|
||||
|
||||
train_dataset = LinearDataset(2, 5, size=data_size)
|
||||
val_dataset = LinearDataset(2, 5, size=val_size)
|
||||
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size)
|
||||
validation_loader = torch.utils.data.DataLoader(val_dataset, batch_size=batch_size)
|
||||
|
||||
model = nn.Linear(1, hidden_size)
|
||||
|
||||
loss_fn = nn.MSELoss()
|
||||
if (
|
||||
accelerator.state.deepspeed_plugin
|
||||
and "optimizer" in accelerator.state.deepspeed_plugin.deepspeed_config
|
||||
):
|
||||
optimizer_cls = DummyOptim
|
||||
elif accelerator.state.deepspeed_plugin:
|
||||
optimizer_cls = DeepSpeedCPUAdam
|
||||
else:
|
||||
optimizer_cls = torch.optim.SGD
|
||||
|
||||
# Accelerate boilerplate
|
||||
no_decay = ["bias", "LayerNorm.weight"]
|
||||
optimizer_grouped_parameters = [
|
||||
{
|
||||
"params": [
|
||||
p
|
||||
for n, p in model.named_parameters()
|
||||
if not any(nd in n for nd in no_decay)
|
||||
],
|
||||
"weight_decay": 0.0,
|
||||
},
|
||||
{
|
||||
"params": [
|
||||
p
|
||||
for n, p in model.named_parameters()
|
||||
if any(nd in n for nd in no_decay)
|
||||
],
|
||||
"weight_decay": 0.0,
|
||||
},
|
||||
]
|
||||
optimizer = optimizer_cls(optimizer_grouped_parameters, lr=lr)
|
||||
train_loader, validation_loader, model, optimizer = accelerator.prepare(
|
||||
train_loader, validation_loader, model, optimizer
|
||||
)
|
||||
|
||||
results = []
|
||||
for _ in range(epochs):
|
||||
for X, y in train_loader:
|
||||
# Compute prediction error
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
# Backpropagation
|
||||
accelerator.backward(loss)
|
||||
optimizer.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
num_batches = len(validation_loader)
|
||||
model.eval()
|
||||
loss = 0
|
||||
with torch.no_grad():
|
||||
for X, y in validation_loader:
|
||||
pred = model(X)
|
||||
loss += loss_fn(pred, y).item()
|
||||
loss /= num_batches
|
||||
import copy
|
||||
|
||||
model_copy = copy.deepcopy(accelerator.unwrap_model(model))
|
||||
state_dict, loss = model_copy.cpu().state_dict(), loss
|
||||
|
||||
result = dict(loss=loss)
|
||||
results.append(result)
|
||||
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
torch.save(state_dict, os.path.join(tmpdir, "checkpoint.pt"))
|
||||
train.report(result, checkpoint=Checkpoint.from_directory(tmpdir))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_gpu", [True, False])
|
||||
def test_accelerate_base(ray_2_node_2_gpu, use_gpu):
|
||||
def train_func(config):
|
||||
accelerator = Accelerator(cpu=not use_gpu)
|
||||
assert accelerator.device == train.torch.get_device()
|
||||
assert accelerator.process_index == train.get_context().get_world_rank()
|
||||
if accelerator.device.type != "cpu":
|
||||
assert (
|
||||
accelerator.local_process_index == train.get_context().get_local_rank()
|
||||
)
|
||||
result = linear_train_func(accelerator, config)
|
||||
assert len(result) == epochs
|
||||
assert result[-1]["loss"] < result[0]["loss"]
|
||||
|
||||
epochs = 3
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=use_gpu)
|
||||
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_accelerate_deepspeed(ray_2_node_2_gpu):
|
||||
from accelerate import DeepSpeedPlugin
|
||||
|
||||
def train_func(config):
|
||||
deepspeed_plugin = DeepSpeedPlugin(hf_ds_config=DEEPSPEED_CONFIG)
|
||||
accelerator = Accelerator(deepspeed_plugin=deepspeed_plugin)
|
||||
|
||||
assert accelerator.device == train.torch.get_device()
|
||||
assert accelerator.process_index == train.get_context().get_world_rank()
|
||||
assert accelerator.local_process_index == train.get_context().get_local_rank()
|
||||
result = linear_train_func(accelerator, config)
|
||||
assert len(result) == epochs
|
||||
assert result[-1]["loss"] < result[0]["loss"]
|
||||
|
||||
epochs = 3
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=True)
|
||||
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
# Using CPU on purpose
|
||||
@pytest.mark.parametrize("num_workers", [1, 2])
|
||||
def test_accelerate_e2e(ray_start_4_cpus, num_workers):
|
||||
def train_func():
|
||||
accelerator = Accelerator(cpu=True)
|
||||
assert accelerator.device == train.torch.get_device()
|
||||
assert accelerator.process_index == train.get_context().get_world_rank()
|
||||
model = torch.nn.Linear(3, 1)
|
||||
model = accelerator.prepare(model)
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
torch.save(model, os.path.join(tmpdir, "checkpoint.pt"))
|
||||
train.report({}, checkpoint=Checkpoint.from_directory(tmpdir))
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=num_workers)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,41 @@
|
||||
import torch
|
||||
|
||||
from ray.train.torch import TorchCheckpoint
|
||||
|
||||
|
||||
def assert_equal_torch_models(model1, model2):
|
||||
# Check equality by comparing their `state_dict`
|
||||
model1_state = model1.state_dict()
|
||||
model2_state = model2.state_dict()
|
||||
assert len(model1_state.keys()) == len(model2_state.keys())
|
||||
for key in model1_state:
|
||||
assert key in model2_state
|
||||
assert torch.equal(model1_state[key], model2_state[key])
|
||||
|
||||
|
||||
def test_from_model():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
checkpoint = TorchCheckpoint.from_model(model)
|
||||
assert_equal_torch_models(checkpoint.get_model(), model)
|
||||
|
||||
with checkpoint.as_directory() as path:
|
||||
checkpoint = TorchCheckpoint.from_directory(path)
|
||||
checkpoint_model = checkpoint.get_model()
|
||||
|
||||
assert_equal_torch_models(checkpoint_model, model)
|
||||
|
||||
|
||||
def test_from_state_dict():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
expected_state_dict = model.state_dict()
|
||||
checkpoint = TorchCheckpoint.from_state_dict(expected_state_dict)
|
||||
actual_state_dict = checkpoint.get_model(torch.nn.Linear(1, 1)).state_dict()
|
||||
assert actual_state_dict == expected_state_dict
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import ray
|
||||
from ray.air._internal.device_manager import (
|
||||
CUDATorchDeviceManager,
|
||||
NPUTorchDeviceManager,
|
||||
get_torch_device_manager_by_context,
|
||||
)
|
||||
from ray.air._internal.device_manager.npu import NPU_TORCH_PACKAGE_AVAILABLE
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
if NPU_TORCH_PACKAGE_AVAILABLE:
|
||||
import torch_npu # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_npus():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=4, resources={"NPU": 2})
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_1_node_1_gpu_1_npu():
|
||||
cluster = Cluster()
|
||||
cluster.add_node(num_cpus=4, num_gpus=1, resources={"NPU": 1})
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def test_cuda_device_manager(ray_2_node_2_gpu):
|
||||
def train_fn():
|
||||
assert isinstance(get_torch_device_manager_by_context(), CUDATorchDeviceManager)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_fn,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=1, use_gpu=True, resources_per_worker={"GPU": 1}
|
||||
),
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_npu_device_manager(ray_2_node_2_npus):
|
||||
def train_fn():
|
||||
assert isinstance(get_torch_device_manager_by_context(), NPUTorchDeviceManager)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_fn,
|
||||
scaling_config=ScalingConfig(num_workers=1, resources_per_worker={"NPU": 1}),
|
||||
)
|
||||
|
||||
if NPU_TORCH_PACKAGE_AVAILABLE and torch.npu.is_available():
|
||||
# Except test run successfully when torch npu is available.
|
||||
trainer.fit()
|
||||
else:
|
||||
# A RuntimeError will be triggered when NPU resources are declared
|
||||
# but the torch npu is actually not available
|
||||
with pytest.raises(RuntimeError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_device_manager_conflict(ray_1_node_1_gpu_1_npu):
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=lambda: None,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=1, use_gpu=True, resources_per_worker={"GPU": 1, "NPU": 1}
|
||||
),
|
||||
)
|
||||
# TODO: Do validation at the `ScalingConfig.__post_init__` level instead.
|
||||
with pytest.raises(RuntimeError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
import torch
|
||||
from torch.distributed.fsdp import FullyShardedDataParallel
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_2_gpus():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_torch_fsdp(ray_start_4_cpus_2_gpus):
|
||||
"""Tests if ``prepare_model`` correctly wraps in FSDP."""
|
||||
|
||||
def train_fn():
|
||||
model = torch.nn.Linear(1, 1)
|
||||
|
||||
# Wrap in FSDP.
|
||||
model = train.torch.prepare_model(model, parallel_strategy="fsdp")
|
||||
|
||||
# Make sure model is wrapped in FSDP.
|
||||
assert isinstance(model, FullyShardedDataParallel)
|
||||
|
||||
# Make sure the model is on cuda.
|
||||
assert next(model.parameters()).is_cuda
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn, scaling_config=ScalingConfig(num_workers=2, use_gpu=True)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", "-s", __file__]))
|
||||
@@ -0,0 +1,213 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.lightning import (
|
||||
RayDDPStrategy,
|
||||
RayDeepSpeedStrategy,
|
||||
RayFSDPStrategy,
|
||||
RayLightningEnvironment,
|
||||
RayTrainReportCallback,
|
||||
)
|
||||
from ray.train.lightning._lightning_utils import import_lightning
|
||||
from ray.train.tests.lightning_test_utils import DummyDataModule, LinearModule
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
pl = import_lightning()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_6_cpus_2_gpus():
|
||||
address_info = ray.init(num_cpus=6, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_6_cpus_4_gpus():
|
||||
address_info = ray.init(num_cpus=6, num_gpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_name", ["ddp", "fsdp"])
|
||||
@pytest.mark.parametrize("accelerator", ["cpu", "gpu"])
|
||||
@pytest.mark.parametrize("datasource", ["dataloader", "datamodule"])
|
||||
def test_trainer_with_native_dataloader(
|
||||
ray_start_6_cpus_2_gpus, strategy_name, accelerator, datasource
|
||||
):
|
||||
"""Test basic ddp and fsdp training with dataloader and datamodule."""
|
||||
|
||||
if accelerator == "cpu" and strategy_name == "fsdp":
|
||||
return
|
||||
|
||||
num_workers = 2
|
||||
num_epochs = 4
|
||||
batch_size = 8
|
||||
dataset_size = 256
|
||||
|
||||
strategy_map = {"ddp": RayDDPStrategy(), "fsdp": RayFSDPStrategy()}
|
||||
|
||||
def train_loop():
|
||||
model = LinearModule(input_dim=32, output_dim=4, strategy=strategy_name)
|
||||
|
||||
strategy = strategy_map[strategy_name]
|
||||
|
||||
trainer = pl.Trainer(
|
||||
max_epochs=num_epochs,
|
||||
devices="auto",
|
||||
accelerator=accelerator,
|
||||
strategy=strategy,
|
||||
plugins=[RayLightningEnvironment()],
|
||||
callbacks=[RayTrainReportCallback()],
|
||||
)
|
||||
|
||||
datamodule = DummyDataModule(batch_size, dataset_size)
|
||||
|
||||
if datasource == "dataloader":
|
||||
trainer.fit(
|
||||
model,
|
||||
train_dataloaders=datamodule.train_dataloader(),
|
||||
val_dataloaders=datamodule.val_dataloader(),
|
||||
)
|
||||
if datasource == "datamodule":
|
||||
trainer.fit(model, datamodule=datamodule)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_loop,
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=(accelerator == "gpu")),
|
||||
)
|
||||
|
||||
results = trainer.fit()
|
||||
assert results.metrics["epoch"] == num_epochs - 1
|
||||
assert (
|
||||
results.metrics["step"] == num_epochs * dataset_size / num_workers / batch_size
|
||||
)
|
||||
assert "loss" in results.metrics
|
||||
assert "val_loss" in results.metrics
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_name", ["ddp", "fsdp"])
|
||||
@pytest.mark.parametrize("accelerator", ["cpu", "gpu"])
|
||||
def test_trainer_with_ray_data(ray_start_6_cpus_2_gpus, strategy_name, accelerator):
|
||||
"""Test Data integration with ddp and fsdp."""
|
||||
|
||||
if accelerator == "cpu" and strategy_name == "fsdp":
|
||||
return
|
||||
|
||||
num_epochs = 4
|
||||
batch_size = 8
|
||||
num_workers = 2
|
||||
dataset_size = 256
|
||||
|
||||
strategy_map = {"ddp": RayDDPStrategy(), "fsdp": RayFSDPStrategy()}
|
||||
|
||||
dataset = np.random.rand(dataset_size, 32).astype(np.float32)
|
||||
train_dataset = ray.data.from_numpy(dataset)
|
||||
val_dataset = ray.data.from_numpy(dataset)
|
||||
|
||||
def train_loop():
|
||||
model = LinearModule(input_dim=32, output_dim=4, strategy=strategy_name)
|
||||
|
||||
strategy = strategy_map[strategy_name]
|
||||
|
||||
trainer = pl.Trainer(
|
||||
max_epochs=num_epochs,
|
||||
devices="auto",
|
||||
accelerator=accelerator,
|
||||
strategy=strategy,
|
||||
plugins=[RayLightningEnvironment()],
|
||||
callbacks=[RayTrainReportCallback()],
|
||||
)
|
||||
|
||||
train_data_iterable = ray.train.get_dataset_shard("train").iter_torch_batches(
|
||||
batch_size=batch_size
|
||||
)
|
||||
val_data_iterable = ray.train.get_dataset_shard("val").iter_torch_batches(
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
trainer.fit(
|
||||
model,
|
||||
train_dataloaders=train_data_iterable,
|
||||
val_dataloaders=val_data_iterable,
|
||||
)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_loop,
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=(accelerator == "gpu")),
|
||||
datasets={"train": train_dataset, "val": val_dataset},
|
||||
)
|
||||
|
||||
results = trainer.fit()
|
||||
assert results.metrics["epoch"] == num_epochs - 1
|
||||
assert (
|
||||
results.metrics["step"] == num_epochs * dataset_size / num_workers / batch_size
|
||||
)
|
||||
assert "loss" in results.metrics
|
||||
assert "val_loss" in results.metrics
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", [1, 2, 3])
|
||||
def test_deepspeed_zero_stages(ray_start_6_cpus_4_gpus, tmpdir, stage):
|
||||
num_epochs = 5
|
||||
batch_size = 8
|
||||
num_workers = 4
|
||||
dataset_size = 256
|
||||
|
||||
def train_loop():
|
||||
model = LinearModule(input_dim=32, output_dim=4, strategy="deepspeed")
|
||||
|
||||
strategy = RayDeepSpeedStrategy(stage=stage)
|
||||
|
||||
trainer = pl.Trainer(
|
||||
max_epochs=num_epochs,
|
||||
devices="auto",
|
||||
accelerator="gpu",
|
||||
strategy=strategy,
|
||||
plugins=[RayLightningEnvironment()],
|
||||
callbacks=[RayTrainReportCallback()],
|
||||
)
|
||||
|
||||
datamodule = DummyDataModule(batch_size, dataset_size)
|
||||
trainer.fit(model, datamodule=datamodule)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_loop,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=True),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
# Check all deepspeed model/optimizer shards are saved
|
||||
all_files = os.listdir(f"{result.checkpoint.path}/checkpoint.ckpt/checkpoint")
|
||||
for rank in range(num_workers):
|
||||
full_model = "mp_rank_00_model_states.pt"
|
||||
model_shard = f"zero_pp_rank_{rank}_mp_rank_00_model_states.pt"
|
||||
optim_shard = f"zero_pp_rank_{rank}_mp_rank_00_optim_states.pt"
|
||||
|
||||
assert (
|
||||
optim_shard in all_files
|
||||
), f"[stage-{stage}] Optimizer states `{optim_shard}` doesn't exist!"
|
||||
|
||||
if stage == 3:
|
||||
assert (
|
||||
model_shard in all_files
|
||||
), f"[stage-{stage}] Model states {model_shard} doesn't exist!"
|
||||
else:
|
||||
assert (
|
||||
full_model in all_files
|
||||
), f"[stage-{stage}] Model states {full_model} doesn't exist!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,306 @@
|
||||
import contextlib
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import ray
|
||||
import ray.train as train
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import (
|
||||
train_func as linear_train_func,
|
||||
)
|
||||
from ray.train.torch import TorchCheckpoint, TorchConfig, TorchTrainer
|
||||
from ray.train.trainer import TrainingFailedError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ray_start_2_node_cluster(num_cpus_per_node: int, num_gpus_per_node: int):
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=num_cpus_per_node, num_gpus=num_gpus_per_node)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 2])
|
||||
def test_torch_linear(ray_start_4_cpus, num_workers):
|
||||
def train_func(config):
|
||||
result = linear_train_func(config)
|
||||
assert len(result) == epochs
|
||||
assert result[-1]["loss"] < result[0]["loss"]
|
||||
|
||||
num_workers = num_workers
|
||||
epochs = 3
|
||||
scaling_config = ScalingConfig(num_workers=num_workers)
|
||||
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare_model", (True, False))
|
||||
def test_torch_e2e(ray_start_4_cpus, prepare_model):
|
||||
def train_func():
|
||||
model = torch.nn.Linear(3, 1)
|
||||
if prepare_model:
|
||||
model = train.torch.prepare_model(model)
|
||||
train.report({}, checkpoint=TorchCheckpoint.from_model(model))
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare_model", (True, False))
|
||||
def test_torch_e2e_state_dict(ray_start_4_cpus, prepare_model):
|
||||
def train_func():
|
||||
model = torch.nn.Linear(3, 1)
|
||||
if prepare_model:
|
||||
model = train.torch.prepare_model(model)
|
||||
train.report({}, checkpoint=TorchCheckpoint.from_state_dict(model.state_dict()))
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
# If loading from a state dict, a model definition must be passed in.
|
||||
with pytest.raises(ValueError):
|
||||
torch_checkpoint = TorchCheckpoint(
|
||||
path=result.checkpoint.path, filesystem=result.checkpoint.filesystem
|
||||
)
|
||||
torch_checkpoint.get_model()
|
||||
|
||||
|
||||
def test_checkpoint_freq(ray_start_4_cpus):
|
||||
# checkpoint_freq is not supported so raise an error
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=lambda config: None,
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
run_config=train.RunConfig(
|
||||
checkpoint_config=train.CheckpointConfig(
|
||||
checkpoint_frequency=2,
|
||||
),
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_torch_session_errors(ray_start_4_cpus):
|
||||
"""Test fail-fast behavior when reporting dicts with Torch tensors"""
|
||||
|
||||
def train_func():
|
||||
model = torch.nn.Linear(1, 1).state_dict()
|
||||
with pytest.raises(ValueError):
|
||||
train.report(model)
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_single_worker_failure(ray_start_4_cpus):
|
||||
"""Tests if training fails upon any worker failure."""
|
||||
|
||||
def single_worker_fail():
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
raise RuntimeError
|
||||
else:
|
||||
time.sleep(1000000)
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=single_worker_fail,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
with pytest.raises(TrainingFailedError) as exc_info:
|
||||
trainer.fit()
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_gpus_per_worker", [0.5, 1, 2])
|
||||
def test_tune_torch_get_device_gpu(num_gpus_per_worker):
|
||||
"""Tests if GPU ids are set correctly when running train concurrently in nested actors
|
||||
(for example when used with Tune).
|
||||
"""
|
||||
from ray.train import ScalingConfig
|
||||
|
||||
num_samples = 2
|
||||
num_workers = 2
|
||||
|
||||
# We should have exactly enough resources in the cluster to run both samples
|
||||
# concurrently.
|
||||
total_gpus_required = num_workers * num_gpus_per_worker * num_samples
|
||||
# Divide by two because of a 2 node cluster.
|
||||
gpus_per_node = total_gpus_required // 2
|
||||
|
||||
exception = None
|
||||
# Use the same number of cpus per node as gpus per node.
|
||||
with ray_start_2_node_cluster(
|
||||
num_cpus_per_node=gpus_per_node, num_gpus_per_node=gpus_per_node
|
||||
):
|
||||
|
||||
@patch("torch.cuda.is_available", lambda: True)
|
||||
def train_fn():
|
||||
# We use STRICT_SPREAD strategy to force multiple samples on the same node.
|
||||
# For single or fractional GPU case, each worker has only 1 visible device (
|
||||
# the other is taken by the other sample) so device index should be 0.
|
||||
# For the multiple GPU case, each worker has 2 visible devices so device
|
||||
# index should be either 0 or 1. It doesn't matter which.
|
||||
device_ids = sorted([device.index for device in train.torch.get_devices()])
|
||||
assert device_ids in [[0], [0, 1]]
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class TrialActor:
|
||||
def __init__(self, warmup_steps):
|
||||
self.trainer = TorchTrainer(
|
||||
train_fn,
|
||||
torch_config=TorchConfig(backend="gloo"),
|
||||
run_config=RunConfig(
|
||||
# Use a unique name to avoid using the same
|
||||
# experiment directory
|
||||
name=f"test_tune_torch_get_device_gpu_{uuid.uuid4()}"
|
||||
),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
use_gpu=True,
|
||||
resources_per_worker={"CPU": 1, "GPU": num_gpus_per_worker},
|
||||
# Need to specify 0 trainer resources so STRICT_SPREAD
|
||||
# will work.
|
||||
trainer_resources={"CPU": 0},
|
||||
placement_strategy="STRICT_SPREAD",
|
||||
# Each gpu worker will be spread onto separate nodes. This
|
||||
# forces different samples to run concurrently on the same
|
||||
# node.
|
||||
),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
return self.trainer.fit()
|
||||
|
||||
try:
|
||||
actors = [TrialActor.remote(1) for _ in range(num_samples)]
|
||||
ray.get([actor.run.remote() for actor in actors])
|
||||
except Exception as exc:
|
||||
exception = exc
|
||||
|
||||
# Raise exception after Ray cluster has been shutdown to avoid corrupted state
|
||||
if exception:
|
||||
raise exception
|
||||
|
||||
|
||||
def test_torch_amp(ray_start_4_cpus):
|
||||
def train_fn():
|
||||
train.torch.accelerate(amp=True)
|
||||
model = torch.nn.Linear(1, 1)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
train.report({}, checkpoint=TorchCheckpoint.from_model(model))
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
)
|
||||
results = trainer.fit()
|
||||
assert results.checkpoint
|
||||
|
||||
|
||||
def test_torch_amp_with_custom_get_state(ray_start_4_cpus):
|
||||
"""Tests amp with a model that has a custom __getstate__ method defined.
|
||||
|
||||
See https://discuss.ray.io/t/ray-train-hangs-for-long-time/6333/7
|
||||
"""
|
||||
|
||||
def train_fn():
|
||||
train.torch.accelerate(amp=True)
|
||||
|
||||
class CustomLinear(torch.nn.Linear):
|
||||
def __getstate__(self):
|
||||
return self.__dict__.copy()
|
||||
|
||||
model = CustomLinear(1, 1)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
# TorchCheckpoint.from_model fails, so just save the state dict only.
|
||||
train.report(
|
||||
{}, checkpoint=TorchCheckpoint.from_state_dict(model.module.state_dict())
|
||||
)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_fn,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
)
|
||||
results = trainer.fit()
|
||||
assert results.checkpoint
|
||||
|
||||
|
||||
def test_torch_env_vars(ray_start_4_cpus):
|
||||
"""Check that env vars are set as expected."""
|
||||
|
||||
def train_func(config):
|
||||
context = train.get_context()
|
||||
assert os.environ["LOCAL_RANK"] == str(context.get_local_rank())
|
||||
assert os.environ["RANK"] == str(context.get_world_rank())
|
||||
assert os.environ["LOCAL_WORLD_SIZE"] == str(context.get_local_world_size())
|
||||
assert os.environ["WORLD_SIZE"] == str(context.get_world_size())
|
||||
assert os.environ["NODE_RANK"] == str(context.get_node_rank())
|
||||
|
||||
assert os.environ["ACCELERATE_TORCH_DEVICE"] == str(train.torch.get_device())
|
||||
|
||||
num_workers = 1
|
||||
scaling_config = ScalingConfig(num_workers=num_workers)
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_nonserializable_train_function(ray_start_4_cpus):
|
||||
import threading
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
def train_func():
|
||||
print(lock)
|
||||
|
||||
trainer = TorchTrainer(train_func)
|
||||
# Check that the `inspect_serializability` trace was printed
|
||||
with pytest.raises(TypeError, match=r".*was found to be non-serializable.*"):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,304 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from datasets import Dataset
|
||||
from transformers import AutoConfig, AutoModelForCausalLM, Trainer, TrainingArguments
|
||||
|
||||
import ray.data
|
||||
from ray import tune
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.huggingface.transformers import RayTrainReportCallback, prepare_trainer
|
||||
from ray.train.tests._huggingface_data import train_data, validation_data
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.tune import Tuner
|
||||
from ray.tune.schedulers.async_hyperband import ASHAScheduler
|
||||
from ray.tune.schedulers.resource_changing_scheduler import (
|
||||
DistributeResources,
|
||||
ResourceChangingScheduler,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_6_cpus_2_gpus():
|
||||
address_info = ray.init(num_cpus=6, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_8_cpus():
|
||||
address_info = ray.init(num_cpus=8)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
# We are only testing Causal Language Modeling here
|
||||
MODEL_NAME = "hf-internal-testing/tiny-random-BloomForCausalLM"
|
||||
|
||||
# Training Loop Configurations
|
||||
NUM_WORKERS = 2
|
||||
BATCH_SIZE_PER_WORKER = 2
|
||||
TRAIN_DATASET_SIZE = 16
|
||||
MAX_EPOCHS = 4
|
||||
|
||||
STEPS_PER_EPOCH = TRAIN_DATASET_SIZE // (BATCH_SIZE_PER_WORKER * NUM_WORKERS)
|
||||
MAX_STEPS = MAX_EPOCHS * STEPS_PER_EPOCH
|
||||
|
||||
# Transformers Traienr Configurations
|
||||
CONFIGURATIONS = {
|
||||
"epoch_gpu": {
|
||||
"evaluation_strategy": "epoch",
|
||||
"save_strategy": "epoch",
|
||||
"logging_strategy": "epoch",
|
||||
"eval_steps": None,
|
||||
"save_steps": None,
|
||||
"logging_steps": None,
|
||||
"no_cuda": False,
|
||||
"use_dict_eval_datasets": False,
|
||||
},
|
||||
"steps_gpu": {
|
||||
"evaluation_strategy": "steps",
|
||||
"save_strategy": "steps",
|
||||
"logging_strategy": "steps",
|
||||
"eval_steps": STEPS_PER_EPOCH,
|
||||
"save_steps": STEPS_PER_EPOCH * 2,
|
||||
"logging_steps": 1,
|
||||
"no_cuda": False,
|
||||
"use_dict_eval_datasets": False,
|
||||
},
|
||||
"steps_cpu": {
|
||||
"evaluation_strategy": "steps",
|
||||
"save_strategy": "steps",
|
||||
"logging_strategy": "steps",
|
||||
"eval_steps": STEPS_PER_EPOCH,
|
||||
"save_steps": STEPS_PER_EPOCH,
|
||||
"logging_steps": 1,
|
||||
"no_cuda": True,
|
||||
"use_dict_eval_datasets": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def train_func(config):
|
||||
# Datasets
|
||||
if config["use_ray_data"]:
|
||||
train_ds_shard = ray.train.get_dataset_shard("train")
|
||||
train_dataset = train_ds_shard.iter_torch_batches(
|
||||
batch_size=BATCH_SIZE_PER_WORKER
|
||||
)
|
||||
if config["use_dict_eval_datasets"]:
|
||||
eval_ds_shard_1 = ray.train.get_dataset_shard("eval_1")
|
||||
eval_ds_shard_2 = ray.train.get_dataset_shard("eval_2")
|
||||
|
||||
eval_dataset = {
|
||||
"eval_1": eval_ds_shard_1.iter_torch_batches(
|
||||
batch_size=BATCH_SIZE_PER_WORKER
|
||||
),
|
||||
"eval_2": eval_ds_shard_2.iter_torch_batches(
|
||||
batch_size=BATCH_SIZE_PER_WORKER
|
||||
),
|
||||
}
|
||||
else:
|
||||
eval_ds_shard = ray.train.get_dataset_shard("eval")
|
||||
|
||||
eval_dataset = eval_ds_shard.iter_torch_batches(
|
||||
batch_size=BATCH_SIZE_PER_WORKER
|
||||
)
|
||||
else:
|
||||
train_df = pd.read_json(train_data)
|
||||
validation_df = pd.read_json(validation_data)
|
||||
|
||||
train_dataset = Dataset.from_pandas(train_df)
|
||||
eval_dataset = Dataset.from_pandas(validation_df)
|
||||
|
||||
# Model
|
||||
model_config = AutoConfig.from_pretrained(MODEL_NAME)
|
||||
model = AutoModelForCausalLM.from_config(model_config)
|
||||
|
||||
# HF Transformers Trainer
|
||||
training_args = TrainingArguments(
|
||||
f"{MODEL_NAME}-wikitext2",
|
||||
eval_strategy=config["evaluation_strategy"],
|
||||
logging_strategy=config["logging_strategy"],
|
||||
save_strategy=config["save_strategy"],
|
||||
eval_steps=config["eval_steps"],
|
||||
save_steps=config["save_steps"],
|
||||
logging_steps=config["logging_steps"],
|
||||
num_train_epochs=config.get("num_train_epochs", MAX_EPOCHS),
|
||||
max_steps=config.get("max_steps", -1),
|
||||
learning_rate=config.get("learning_rate", 2e-5),
|
||||
per_device_train_batch_size=BATCH_SIZE_PER_WORKER,
|
||||
per_device_eval_batch_size=BATCH_SIZE_PER_WORKER,
|
||||
weight_decay=0.01,
|
||||
disable_tqdm=True,
|
||||
use_cpu=config["no_cuda"],
|
||||
report_to="none",
|
||||
)
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
args=training_args,
|
||||
train_dataset=train_dataset,
|
||||
eval_dataset=eval_dataset,
|
||||
)
|
||||
|
||||
# Report to Ray Train
|
||||
trainer.add_callback(RayTrainReportCallback())
|
||||
trainer = prepare_trainer(trainer)
|
||||
|
||||
# Start Training
|
||||
trainer.train()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_id", ["epoch_gpu", "steps_gpu", "steps_cpu"])
|
||||
def test_e2e_hf_data(ray_start_6_cpus_2_gpus, config_id):
|
||||
train_loop_config = CONFIGURATIONS[config_id]
|
||||
|
||||
# Specify `num_train_epochs` for Map-style Dataset
|
||||
train_loop_config["use_ray_data"] = False
|
||||
train_loop_config["num_train_epochs"] = MAX_EPOCHS
|
||||
|
||||
# Calculate the num of Ray training iterations
|
||||
if train_loop_config["save_strategy"] == "steps":
|
||||
num_iterations = MAX_STEPS // train_loop_config["save_steps"]
|
||||
else:
|
||||
num_iterations = MAX_EPOCHS
|
||||
|
||||
use_gpu = not train_loop_config["no_cuda"]
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=ScalingConfig(num_workers=NUM_WORKERS, use_gpu=use_gpu),
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
assert result.metrics["epoch"] == MAX_EPOCHS
|
||||
assert result.metrics["step"] == MAX_STEPS
|
||||
assert result.metrics["training_iteration"] == num_iterations
|
||||
assert result.checkpoint
|
||||
assert isinstance(result.checkpoint, Checkpoint)
|
||||
assert len(result.best_checkpoints) == num_iterations
|
||||
assert "eval_loss" in result.metrics
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_id", ["steps_gpu", "steps_cpu"])
|
||||
def test_e2e_ray_data(ray_start_6_cpus_2_gpus, config_id):
|
||||
train_loop_config = CONFIGURATIONS[config_id]
|
||||
|
||||
# Must specify `max_steps` for Iterable Dataset
|
||||
train_loop_config["use_ray_data"] = True
|
||||
train_loop_config["max_steps"] = MAX_STEPS
|
||||
|
||||
# Calculate the num of Ray training iterations
|
||||
num_iterations = MAX_STEPS // train_loop_config["save_steps"]
|
||||
|
||||
train_df = pd.read_json(train_data)
|
||||
validation_df = pd.read_json(validation_data)
|
||||
|
||||
ray_train_ds = ray.data.from_pandas(train_df)
|
||||
ray_eval_ds = ray.data.from_pandas(validation_df)
|
||||
|
||||
use_gpu = not train_loop_config["no_cuda"]
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=ScalingConfig(num_workers=NUM_WORKERS, use_gpu=use_gpu),
|
||||
datasets={"train": ray_train_ds, "eval": ray_eval_ds},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
assert result.metrics["step"] == MAX_STEPS
|
||||
assert result.metrics["training_iteration"] == num_iterations
|
||||
assert result.checkpoint
|
||||
assert isinstance(result.checkpoint, Checkpoint)
|
||||
assert len(result.best_checkpoints) == num_iterations
|
||||
assert "eval_loss" in result.metrics
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_id", ["steps_gpu", "steps_cpu"])
|
||||
def test_e2e_dict_eval_ray_data(ray_start_6_cpus_2_gpus, config_id):
|
||||
train_loop_config = CONFIGURATIONS[config_id]
|
||||
|
||||
# Must specify `max_steps` for Iterable Dataset
|
||||
train_loop_config["use_ray_data"] = True
|
||||
train_loop_config["use_dict_eval_datasets"] = True
|
||||
train_loop_config["max_steps"] = MAX_STEPS
|
||||
|
||||
# Calculate the num of Ray training iterations
|
||||
num_iterations = MAX_STEPS // train_loop_config["save_steps"]
|
||||
|
||||
train_df = pd.read_json(train_data)
|
||||
validation_df = pd.read_json(validation_data)
|
||||
|
||||
ray_train_ds = ray.data.from_pandas(train_df)
|
||||
ray_eval_ds_1 = ray.data.from_pandas(validation_df)
|
||||
ray_eval_ds_2 = ray.data.from_pandas(validation_df)
|
||||
|
||||
use_gpu = not train_loop_config["no_cuda"]
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=ScalingConfig(num_workers=NUM_WORKERS, use_gpu=use_gpu),
|
||||
datasets={
|
||||
"train": ray_train_ds,
|
||||
"eval_1": ray_eval_ds_1,
|
||||
"eval_2": ray_eval_ds_2,
|
||||
},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
assert result.metrics["step"] == MAX_STEPS
|
||||
assert result.metrics["training_iteration"] == num_iterations
|
||||
assert result.checkpoint
|
||||
assert isinstance(result.checkpoint, Checkpoint)
|
||||
assert len(result.best_checkpoints) == num_iterations
|
||||
assert "eval_eval_1_loss" in result.metrics
|
||||
assert "eval_eval_2_loss" in result.metrics
|
||||
|
||||
|
||||
# Tests if Ray Tune works correctly.
|
||||
def test_tune(ray_start_8_cpus):
|
||||
train_loop_config = CONFIGURATIONS["steps_cpu"]
|
||||
train_loop_config["use_ray_data"] = False
|
||||
|
||||
use_gpu = not train_loop_config["no_cuda"]
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=train_loop_config,
|
||||
scaling_config=ScalingConfig(num_workers=NUM_WORKERS, use_gpu=use_gpu),
|
||||
)
|
||||
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={
|
||||
"train_loop_config": {
|
||||
"learning_rate": tune.loguniform(2e-6, 2e-5),
|
||||
}
|
||||
},
|
||||
tune_config=tune.TuneConfig(
|
||||
metric="eval_loss",
|
||||
mode="min",
|
||||
num_samples=3,
|
||||
scheduler=ResourceChangingScheduler(
|
||||
ASHAScheduler(
|
||||
max_t=MAX_EPOCHS,
|
||||
),
|
||||
resources_allocation_function=DistributeResources(
|
||||
add_bundles=True, reserve_resources={"CPU": 1}
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
tune_results = tuner.fit()
|
||||
assert not tune_results.errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from ray.air._internal.torch_utils import (
|
||||
contains_tensor,
|
||||
load_torch_model,
|
||||
)
|
||||
|
||||
torch_module = torch.nn.Linear(1, 1)
|
||||
|
||||
|
||||
class TestLoadTorchModel:
|
||||
def test_load_module(self):
|
||||
assert load_torch_model(torch_module) == torch_module
|
||||
|
||||
def test_load_state_dict(self):
|
||||
state_dict = torch_module.state_dict()
|
||||
model_definition = torch.nn.Linear(1, 1)
|
||||
assert model_definition.state_dict() != state_dict
|
||||
|
||||
assert load_torch_model(state_dict, model_definition).state_dict() == state_dict
|
||||
|
||||
def test_load_state_dict_fail(self):
|
||||
with pytest.raises(ValueError):
|
||||
# model_definition is required to load state dict.
|
||||
load_torch_model(torch_module.state_dict())
|
||||
|
||||
|
||||
def test_contains_tensor():
|
||||
t = torch.tensor([0])
|
||||
assert contains_tensor(t)
|
||||
assert contains_tensor([1, 2, 3, t, 5, 6])
|
||||
assert contains_tensor([1, 2, 3, {"dict": t}, 5, 6])
|
||||
assert contains_tensor({"outer": [1, 2, 3, {"dict": t}, 5, 6]})
|
||||
assert contains_tensor({t: [1, 2, 3, {"dict": 2}, 5, 6]})
|
||||
assert not contains_tensor([4, 5, 6])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,74 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import ray
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_8_cpus(monkeypatch):
|
||||
monkeypatch.setenv("RAY_TRAIN_ENABLE_STATE_TRACKING", "1")
|
||||
address_info = ray.init(num_cpus=8)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_get_train_runs(ray_start_8_cpus):
|
||||
def train_func():
|
||||
print("Training Starts")
|
||||
time.sleep(0.5)
|
||||
|
||||
datasets = {"train": ray.data.range(100), "val": ray.data.range(100)}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
run_config=RunConfig(name="my_train_run", storage_path="/tmp/cluster_storage"),
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=False),
|
||||
datasets=datasets,
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
# Call the train run api
|
||||
url = ray._private.worker.get_dashboard_url()
|
||||
resp = requests.get("http://" + url + "/api/train/v2/runs")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["train_runs"]) == 1
|
||||
assert body["train_runs"][0]["name"] == "my_train_run"
|
||||
assert len(body["train_runs"][0]["workers"]) == 4
|
||||
|
||||
|
||||
def test_add_actor_status(ray_start_8_cpus):
|
||||
from ray.train._internal.state.schema import ActorStatusEnum
|
||||
|
||||
def check_actor_status(expected_actor_status):
|
||||
url = ray._private.worker.get_dashboard_url()
|
||||
resp = requests.get("http://" + url + "/api/train/v2/runs")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
for worker_info in body["train_runs"][0]["workers"]:
|
||||
assert worker_info["status"] == expected_actor_status
|
||||
|
||||
def train_func():
|
||||
print("Training Starts")
|
||||
time.sleep(0.5)
|
||||
check_actor_status(expected_actor_status=ActorStatusEnum.ALIVE)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
run_config=RunConfig(name="my_train_run", storage_path="/tmp/cluster_storage"),
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=False),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
check_actor_status(expected_actor_status=ActorStatusEnum.DEAD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,148 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shutdown_only():
|
||||
yield None
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def run_torch():
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
|
||||
from ray.train.torch import (
|
||||
get_device,
|
||||
get_devices,
|
||||
prepare_data_loader,
|
||||
prepare_model,
|
||||
)
|
||||
|
||||
def train_func():
|
||||
# Create dummy model and data loader
|
||||
model = torch.nn.Linear(10, 10)
|
||||
inputs, targets = torch.randn(128, 10), torch.randn(128, 1)
|
||||
dataloader = DataLoader(TensorDataset(inputs, targets), batch_size=32)
|
||||
|
||||
# Test Torch Utilities
|
||||
prepare_data_loader(dataloader)
|
||||
prepare_model(model)
|
||||
get_device()
|
||||
get_devices()
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=False)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def run_lightning():
|
||||
import lightning.pytorch as pl
|
||||
|
||||
from ray.train.lightning import (
|
||||
RayDDPStrategy,
|
||||
RayDeepSpeedStrategy,
|
||||
RayFSDPStrategy,
|
||||
RayLightningEnvironment,
|
||||
RayTrainReportCallback,
|
||||
prepare_trainer,
|
||||
)
|
||||
|
||||
def train_func():
|
||||
# Test Lighting utilites
|
||||
strategy = RayFSDPStrategy()
|
||||
strategy = RayDeepSpeedStrategy()
|
||||
strategy = RayDDPStrategy()
|
||||
ray_environment = RayLightningEnvironment()
|
||||
report_callback = RayTrainReportCallback()
|
||||
|
||||
trainer = pl.Trainer(
|
||||
devices="auto",
|
||||
accelerator="auto",
|
||||
strategy=strategy,
|
||||
plugins=[ray_environment],
|
||||
callbacks=[report_callback],
|
||||
)
|
||||
trainer = prepare_trainer(trainer)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=False)
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def run_transformers():
|
||||
from datasets import Dataset
|
||||
from transformers import Trainer, TrainingArguments
|
||||
|
||||
from ray.train.huggingface.transformers import (
|
||||
RayTrainReportCallback,
|
||||
prepare_trainer,
|
||||
)
|
||||
|
||||
def train_func():
|
||||
# Create dummy model and datasets
|
||||
dataset = Dataset.from_dict({"text": ["text1", "text2"], "label": [0, 1]})
|
||||
model = torch.nn.Linear(10, 10)
|
||||
|
||||
# Test Transformers utilites
|
||||
training_args = TrainingArguments(output_dir="./results", use_cpu=True)
|
||||
trainer = Trainer(model=model, args=training_args, train_dataset=dataset)
|
||||
|
||||
trainer.add_callback(RayTrainReportCallback())
|
||||
trainer = prepare_trainer(trainer)
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=2, use_gpu=False)
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("framework", ["torch", "lightning", "transformers"])
|
||||
def test_torch_utility_usage_tags(shutdown_only, framework):
|
||||
from ray._common.usage.usage_lib import TagKey, get_extra_usage_tags_to_report
|
||||
|
||||
ctx = ray.init()
|
||||
gcs_client = ray._raylet.GcsClient(address=ctx.address_info["gcs_address"])
|
||||
|
||||
if framework == "torch":
|
||||
run_torch()
|
||||
expected_tags = [
|
||||
TagKey.TRAIN_TORCH_GET_DEVICE,
|
||||
TagKey.TRAIN_TORCH_GET_DEVICES,
|
||||
TagKey.TRAIN_TORCH_PREPARE_MODEL,
|
||||
TagKey.TRAIN_TORCH_PREPARE_DATALOADER,
|
||||
]
|
||||
elif framework == "lightning":
|
||||
run_lightning()
|
||||
expected_tags = [
|
||||
TagKey.TRAIN_LIGHTNING_PREPARE_TRAINER,
|
||||
TagKey.TRAIN_LIGHTNING_RAYTRAINREPORTCALLBACK,
|
||||
TagKey.TRAIN_LIGHTNING_RAYDDPSTRATEGY,
|
||||
TagKey.TRAIN_LIGHTNING_RAYFSDPSTRATEGY,
|
||||
TagKey.TRAIN_LIGHTNING_RAYDEEPSPEEDSTRATEGY,
|
||||
TagKey.TRAIN_LIGHTNING_RAYLIGHTNINGENVIRONMENT,
|
||||
]
|
||||
elif framework == "transformers":
|
||||
run_transformers()
|
||||
expected_tags = [
|
||||
TagKey.TRAIN_TRANSFORMERS_PREPARE_TRAINER,
|
||||
TagKey.TRAIN_TRANSFORMERS_RAYTRAINREPORTCALLBACK,
|
||||
]
|
||||
|
||||
result = get_extra_usage_tags_to_report(gcs_client)
|
||||
assert set(result.keys()).issuperset(
|
||||
{TagKey.Name(tag).lower() for tag in expected_tags}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,359 @@
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.air._internal.uri_utils import URI
|
||||
from ray.train import CheckpointConfig, RunConfig, ScalingConfig
|
||||
from ray.train.base_trainer import BaseTrainer
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.lightgbm import LightGBMTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.trainer import TrainingFailedError
|
||||
from ray.train.xgboost import XGBoostTrainer
|
||||
from ray.tune import Callback
|
||||
from ray.tune.experiment import Trial
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
if ray.is_initialized():
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_6_cpus():
|
||||
address_info = ray.init(num_cpus=6)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
if ray.is_initialized():
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
class _TestSpecificError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _failing_train_fn(config):
|
||||
checkpoint = train.get_checkpoint()
|
||||
it = 1
|
||||
if checkpoint:
|
||||
it = load_dict_checkpoint(checkpoint)["it"] + 1
|
||||
print(f"\nLoading from checkpoint, which is at iteration {it}...\n")
|
||||
with create_dict_checkpoint({"it": it}) as checkpoint:
|
||||
train.report({"it": it}, checkpoint=checkpoint)
|
||||
if it == 1:
|
||||
raise _TestSpecificError
|
||||
|
||||
|
||||
class FailureInjectionCallback(Callback):
|
||||
"""Inject failure at the configured iteration number."""
|
||||
|
||||
def __init__(self, fail_marker_path: Path, num_iters: int = 2):
|
||||
self.num_iters = num_iters
|
||||
self.fail_marker_path = fail_marker_path
|
||||
|
||||
def on_trial_result(
|
||||
self, iteration: int, trials: List[Trial], trial: Trial, result: Dict, **info
|
||||
):
|
||||
if not self.fail_marker_path.exists():
|
||||
return
|
||||
|
||||
if trial.last_result.get("training_iteration", -1) >= self.num_iters:
|
||||
print(f"Failing after {self.num_iters} iters...")
|
||||
self.fail_marker_path.unlink()
|
||||
raise _TestSpecificError
|
||||
|
||||
|
||||
def test_data_parallel_trainer_restore(ray_start_4_cpus, tmpdir):
|
||||
"""Restoring a DataParallelTrainer with object refs captured in the train fn
|
||||
or config works by re-specifying them.
|
||||
Success criteria:
|
||||
- Restored to the correct iteration. (1 iteration before crash, 1 after restore).
|
||||
- Results are being logged to the same directory as before.
|
||||
"""
|
||||
dataset_size = 10
|
||||
num_workers = 2
|
||||
|
||||
def create_train_fn_and_config():
|
||||
obj_ref = ray.put({"test": 1})
|
||||
|
||||
def train_fn(config):
|
||||
assert ray.get(obj_ref)["test"] == 1
|
||||
assert ray.get(config["obj_ref"])["test"] == 1
|
||||
ds = train.get_dataset_shard("train")
|
||||
assert (
|
||||
sum([len(batch["feature"]) for batch in ds.iter_batches()])
|
||||
== dataset_size // num_workers
|
||||
)
|
||||
_failing_train_fn(config)
|
||||
|
||||
train_loop_config = {"obj_ref": obj_ref}
|
||||
return train_fn, train_loop_config
|
||||
|
||||
datasets = {"train": ray.data.from_items([{"feature": i} for i in range(10)])}
|
||||
train_fn, train_loop_config = create_train_fn_and_config()
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
datasets=datasets,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
run_config=RunConfig(
|
||||
name="data_parallel_restore_test",
|
||||
storage_path=str(tmpdir),
|
||||
checkpoint_config=CheckpointConfig(num_to_keep=1),
|
||||
),
|
||||
)
|
||||
with pytest.raises(TrainingFailedError) as exc_info:
|
||||
result = trainer.fit()
|
||||
assert isinstance(exc_info.value.__cause__, _TestSpecificError)
|
||||
|
||||
# Include an explicit cluster shutdown.
|
||||
# Otherwise, the previously registered object references will still exist,
|
||||
# and the test may trivially pass.
|
||||
ray.shutdown()
|
||||
ray.init(num_cpus=4)
|
||||
|
||||
train_fn, train_loop_config = create_train_fn_and_config()
|
||||
datasets = {"train": ray.data.from_items([{"feature": i} for i in range(10)])}
|
||||
trainer = DataParallelTrainer.restore(
|
||||
str(tmpdir / "data_parallel_restore_test"),
|
||||
train_loop_per_worker=train_fn,
|
||||
train_loop_config=train_loop_config,
|
||||
datasets=datasets,
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert not result.error
|
||||
assert result.metrics["training_iteration"] == 2
|
||||
assert result.metrics["iterations_since_restore"] == 1
|
||||
assert tmpdir / "data_parallel_restore_test" in Path(result.path).parents
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trainer_cls", [XGBoostTrainer, LightGBMTrainer])
|
||||
def test_gbdt_trainer_restore(ray_start_6_cpus, tmp_path, trainer_cls, monkeypatch):
|
||||
"""Tests restoring gradient boosted decision tree trainers.
|
||||
Success criteria:
|
||||
- Picks up at the right iteration. 2 before crash. 3 after. 5 total trees.
|
||||
- Results are being logged to the same directory as before.
|
||||
"""
|
||||
monkeypatch.setenv("TUNE_GLOBAL_CHECKPOINT_S", "0")
|
||||
exp_name = f"{trainer_cls.__name__}_restore_test"
|
||||
datasets = {
|
||||
"train": ray.data.from_pandas(
|
||||
pd.DataFrame({"x": range(100), "y": range(1, 101)})
|
||||
)
|
||||
}
|
||||
|
||||
fail_marker_path = tmp_path / "fail_marker"
|
||||
fail_marker_path.touch()
|
||||
|
||||
trainer = trainer_cls(
|
||||
label_column="y",
|
||||
params={
|
||||
"objective": (
|
||||
"reg:squarederror" if trainer_cls == XGBoostTrainer else "regression"
|
||||
)
|
||||
},
|
||||
datasets=datasets,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2, trainer_resources={"CPU": 0}, resources_per_worker={"CPU": 1}
|
||||
),
|
||||
run_config=RunConfig(
|
||||
storage_path=str(tmp_path),
|
||||
name=exp_name,
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=1, checkpoint_frequency=1, checkpoint_at_end=False
|
||||
),
|
||||
callbacks=[FailureInjectionCallback(fail_marker_path, num_iters=2)],
|
||||
),
|
||||
num_boost_round=5,
|
||||
)
|
||||
with pytest.raises(TrainingFailedError):
|
||||
result = trainer.fit()
|
||||
|
||||
trainer = trainer_cls.restore(str(tmp_path / exp_name), datasets=datasets)
|
||||
result = trainer.fit()
|
||||
assert not result.error
|
||||
assert result.metrics["training_iteration"] == 5
|
||||
assert result.metrics["iterations_since_restore"] == 3
|
||||
assert tmp_path / exp_name in Path(result.path).parents
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [None, "restore_from_uri"])
|
||||
def test_restore_from_uri_s3(
|
||||
ray_start_4_cpus, tmp_path, monkeypatch, mock_s3_bucket_uri, name
|
||||
):
|
||||
"""Restoration from S3 should work."""
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=lambda config: train.report({"score": 1}),
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
run_config=RunConfig(name=name, storage_path=mock_s3_bucket_uri),
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
if name is None:
|
||||
name = Path(result.path).parent.name
|
||||
|
||||
# Restore from S3
|
||||
assert DataParallelTrainer.can_restore(str(URI(mock_s3_bucket_uri) / name))
|
||||
DataParallelTrainer.restore(str(URI(mock_s3_bucket_uri) / name))
|
||||
|
||||
|
||||
def test_restore_with_datasets(ray_start_4_cpus, tmpdir):
|
||||
"""Datasets are required to re-specify if they were originally provided."""
|
||||
datasets = {
|
||||
"train": ray.data.from_items([{"x": x, "y": x + 1} for x in range(8)]),
|
||||
"valid": ray.data.from_items([{"x": x, "y": x + 1} for x in range(8)]),
|
||||
}
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=lambda config: train.report({"score": 1}),
|
||||
datasets=datasets,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
run_config=RunConfig(name="datasets_respecify_test"),
|
||||
)
|
||||
trainer._save(pyarrow.fs.LocalFileSystem(), str(tmpdir))
|
||||
|
||||
# Restore should complain, if all the datasets don't get passed in again
|
||||
with pytest.raises(ValueError):
|
||||
DataParallelTrainer.restore(str(tmpdir))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DataParallelTrainer.restore(str(tmpdir), datasets={"train": datasets["train"]})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DataParallelTrainer.restore(
|
||||
str(tmpdir),
|
||||
datasets={"train": datasets["train"], "invalid_key": datasets["valid"]},
|
||||
)
|
||||
|
||||
trainer = DataParallelTrainer.restore(str(tmpdir), datasets=datasets)
|
||||
|
||||
|
||||
def test_restore_from_invalid_dir(tmpdir):
|
||||
"""Should raise an error if the restore directory doesn't exist or is invalid."""
|
||||
with pytest.raises(ValueError):
|
||||
BaseTrainer.restore(str(tmpdir))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
BaseTrainer.restore("mock:///not/found")
|
||||
|
||||
|
||||
def test_trainer_can_restore_utility(tmp_path):
|
||||
"""Make sure that `can_restore` detects an existing experiment at a
|
||||
local/remote path and only returns True if it's at the Train experiment dir root.
|
||||
"""
|
||||
name = "exp_name"
|
||||
path = tmp_path / name
|
||||
|
||||
assert not DataParallelTrainer.can_restore(path)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=lambda config: train.report({"score": 1}),
|
||||
scaling_config=ScalingConfig(num_workers=1),
|
||||
)
|
||||
(tmp_path / name).mkdir(exist_ok=True)
|
||||
trainer._save(pyarrow.fs.LocalFileSystem(), str(tmp_path / name))
|
||||
|
||||
assert DataParallelTrainer.can_restore(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("eventual_success", [True, False])
|
||||
def test_retry_with_max_failures(ray_start_4_cpus, eventual_success):
|
||||
"""Test auto-resume of a Train run when setting max_failures > 0."""
|
||||
|
||||
num_failures = 2 if eventual_success else 3
|
||||
max_retries = 2
|
||||
final_iter = 10
|
||||
|
||||
def train_func():
|
||||
ckpt = train.get_checkpoint()
|
||||
itr = 1
|
||||
restore_count = 0
|
||||
if ckpt:
|
||||
ckpt = load_dict_checkpoint(ckpt)
|
||||
itr = ckpt["iter"] + 1
|
||||
restore_count = ckpt["restore_count"] + 1
|
||||
|
||||
for i in range(itr, final_iter + 1):
|
||||
with create_dict_checkpoint(
|
||||
dict(iter=i, restore_count=restore_count)
|
||||
) as checkpoint:
|
||||
train.report(dict(test=i, training_iteration=i), checkpoint=checkpoint)
|
||||
if restore_count < num_failures:
|
||||
raise RuntimeError("try to fail me")
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
run_config=RunConfig(
|
||||
failure_config=train.FailureConfig(max_failures=max_retries)
|
||||
),
|
||||
)
|
||||
|
||||
if not eventual_success:
|
||||
# If we gave up due to hitting our max retry attempts,
|
||||
# then `trainer.fit` should raise the last error we encountered.
|
||||
with pytest.raises(TrainingFailedError):
|
||||
trainer.fit()
|
||||
else:
|
||||
# If we encounter errors but eventually succeed, `trainer.fit` should NOT
|
||||
# raise any of those errors.
|
||||
result = trainer.fit()
|
||||
assert not result.error
|
||||
checkpoint = load_dict_checkpoint(result.checkpoint)
|
||||
assert checkpoint["iter"] == final_iter
|
||||
|
||||
|
||||
def test_restoration_after_termination(tmp_path):
|
||||
"""Test that the train loop can be run again if restoring the trainer
|
||||
after the run finished running successfully."""
|
||||
|
||||
def train_func_per_worker(config, num_epochs=5):
|
||||
ckpt = train.get_checkpoint()
|
||||
start_iter = 1
|
||||
if ckpt:
|
||||
ckpt = load_dict_checkpoint(ckpt)
|
||||
start_iter = ckpt["iter"] + 1
|
||||
|
||||
for i in range(start_iter, num_epochs + 1):
|
||||
with create_dict_checkpoint(dict(iter=i)) as checkpoint:
|
||||
train.report(dict(iter=i), checkpoint=checkpoint)
|
||||
|
||||
name = "exp_name"
|
||||
path = tmp_path / name
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func_per_worker,
|
||||
scaling_config=ScalingConfig(num_workers=1),
|
||||
run_config=RunConfig(
|
||||
name=name,
|
||||
storage_path=tmp_path,
|
||||
checkpoint_config=CheckpointConfig(num_to_keep=2),
|
||||
),
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert result.metrics["iter"] == 5
|
||||
|
||||
restored_trainer = DataParallelTrainer.restore(
|
||||
str(path), train_loop_per_worker=partial(train_func_per_worker, num_epochs=10)
|
||||
)
|
||||
new_result = restored_trainer.fit()
|
||||
assert new_result.metrics["iter"] == 10
|
||||
|
||||
assert new_result.path == result.path
|
||||
assert len(list(Path(new_result.path).glob("checkpoint*"))) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,395 @@
|
||||
import functools
|
||||
import sys
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.air._internal.util import StartTraceback
|
||||
from ray.train import DataConfig
|
||||
from ray.train._internal.backend_executor import BackendExecutor
|
||||
from ray.train._internal.session import get_session, init_session
|
||||
from ray.train._internal.utils import construct_train_func
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import BackendConfig
|
||||
from ray.train.examples.pytorch.torch_linear_example import (
|
||||
train_func as linear_train_func,
|
||||
)
|
||||
from ray.train.tests.util import mock_storage_context
|
||||
from ray.train.trainer import TrainingIterator
|
||||
|
||||
MAX_RETRIES = 3
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def patch_tune_session():
|
||||
if not get_session():
|
||||
init_session(
|
||||
training_func=None,
|
||||
world_rank=None,
|
||||
local_rank=None,
|
||||
node_rank=None,
|
||||
local_world_size=None,
|
||||
world_size=None,
|
||||
storage=mock_storage_context(),
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def gen_execute_single_async_special(special_f):
|
||||
def execute_single_async_special(self, i, f, *args, **kwargs):
|
||||
assert len(self.workers) == 2
|
||||
if i == 0 and hasattr(self, "should_fail") and self.should_fail:
|
||||
kwargs["train_func"] = special_f
|
||||
return (
|
||||
self.workers[i]
|
||||
.actor._RayTrainWorker__execute.options(name=f.__name__)
|
||||
.remote(f, *args, **kwargs)
|
||||
)
|
||||
|
||||
return execute_single_async_special
|
||||
|
||||
|
||||
def gen_new_backend_executor(special_f):
|
||||
"""Returns a BackendExecutor that runs special_f on worker 0 once."""
|
||||
|
||||
class TestBackendExecutor(BackendExecutor):
|
||||
_has_failed = False
|
||||
|
||||
def start_training(self, *args, **kwargs):
|
||||
special_execute = gen_execute_single_async_special(special_f)
|
||||
if not self._has_failed:
|
||||
self.worker_group.should_fail = True
|
||||
self._has_failed = True
|
||||
else:
|
||||
self.worker_group.should_fail = False
|
||||
with patch.object(WorkerGroup, "execute_single_async", special_execute):
|
||||
super().start_training(*args, **kwargs)
|
||||
|
||||
return TestBackendExecutor
|
||||
|
||||
|
||||
def create_iterator(
|
||||
train_func,
|
||||
backend_config,
|
||||
*,
|
||||
num_workers=2,
|
||||
backend_executor_cls=BackendExecutor,
|
||||
init_hook=None,
|
||||
):
|
||||
# Similar logic to the old Trainer.run_iterator().
|
||||
|
||||
train_func = construct_train_func(train_func, None, train_func_context=nullcontext)
|
||||
|
||||
backend_executor = backend_executor_cls(
|
||||
backend_config=backend_config, num_workers=num_workers, max_retries=MAX_RETRIES
|
||||
)
|
||||
backend_executor.start(init_hook)
|
||||
|
||||
return TrainingIterator(
|
||||
backend_executor=backend_executor,
|
||||
backend_config=backend_config,
|
||||
train_func=train_func,
|
||||
datasets={},
|
||||
metadata={},
|
||||
data_config=DataConfig(),
|
||||
checkpoint=None,
|
||||
)
|
||||
|
||||
|
||||
def test_run_iterator(ray_start_4_cpus):
|
||||
config = BackendConfig()
|
||||
|
||||
def train_func():
|
||||
for i in range(3):
|
||||
train.report(dict(index=i))
|
||||
return 1
|
||||
|
||||
iterator = create_iterator(train_func, config)
|
||||
|
||||
count = 0
|
||||
for results in iterator:
|
||||
assert all(value.metrics["index"] == count for value in results)
|
||||
count += 1
|
||||
|
||||
assert count == 3
|
||||
assert iterator.is_finished()
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
next(iterator)
|
||||
|
||||
|
||||
def test_run_iterator_error(ray_start_4_cpus):
|
||||
config = BackendConfig()
|
||||
|
||||
def fail_train():
|
||||
raise NotImplementedError
|
||||
|
||||
iterator = create_iterator(fail_train, config)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
next(iterator)
|
||||
|
||||
assert isinstance(exc.value.__cause__, NotImplementedError), (
|
||||
exc.value,
|
||||
exc.value.__cause__,
|
||||
)
|
||||
|
||||
assert iterator.is_finished()
|
||||
|
||||
|
||||
def test_worker_failure_1(ray_start_4_cpus):
|
||||
def train_func():
|
||||
train.report({"test": 1})
|
||||
|
||||
def train_actor_failure():
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_actor_failure)
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
iterator = create_iterator(
|
||||
train_func, config, backend_executor_cls=new_backend_executor_cls
|
||||
)
|
||||
for worker_results in iterator:
|
||||
assert all(result.metrics["test"] == 1 for result in worker_results)
|
||||
|
||||
|
||||
def test_worker_failure_2(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for _ in range(2):
|
||||
train.report(dict(loss=1))
|
||||
|
||||
def train_actor_failure():
|
||||
for _ in range(2):
|
||||
train.report(dict(loss=1))
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_actor_failure)
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
iterator = create_iterator(
|
||||
train_func, config, backend_executor_cls=new_backend_executor_cls
|
||||
)
|
||||
for worker_results in iterator:
|
||||
assert all(result.metrics["loss"] == 1 for result in worker_results)
|
||||
|
||||
|
||||
def test_worker_failure_local_rank(ray_start_4_cpus):
|
||||
def train_func():
|
||||
train.report({"rank": train.get_context().get_local_rank()})
|
||||
|
||||
def train_actor_failure():
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_actor_failure)
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
iterator = create_iterator(
|
||||
train_func, config, backend_executor_cls=new_backend_executor_cls
|
||||
)
|
||||
for worker_results in iterator:
|
||||
assert {result.metrics["rank"] for result in worker_results} == {0, 1}
|
||||
|
||||
|
||||
def test_worker_start_failure(ray_start_4_cpus):
|
||||
def init_hook():
|
||||
pass
|
||||
|
||||
def init_hook_fail():
|
||||
ray.actor.exit_actor()
|
||||
|
||||
class TestBackendExecutor(BackendExecutor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _restart(self):
|
||||
self._initialization_hook = init_hook
|
||||
super()._restart()
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
iterator = create_iterator(
|
||||
lambda x: 1,
|
||||
config,
|
||||
backend_executor_cls=TestBackendExecutor,
|
||||
init_hook=init_hook_fail,
|
||||
)
|
||||
assert len(iterator._backend_executor.get_worker_group()) == 2
|
||||
|
||||
|
||||
def test_max_failures(ray_start_4_cpus):
|
||||
def train_func():
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
iterator = create_iterator(train_func, config)
|
||||
with pytest.raises(RuntimeError):
|
||||
for _ in iterator:
|
||||
pass
|
||||
assert iterator._backend_executor._get_num_failures() == MAX_RETRIES
|
||||
|
||||
|
||||
def test_start_max_failures(ray_start_4_cpus):
|
||||
def init_hook_fail():
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
config = BackendConfig()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
create_iterator(lambda x: 1, config, init_hook=init_hook_fail)
|
||||
|
||||
|
||||
class KillCallback:
|
||||
def __init__(self, fail_on, backend_executor):
|
||||
self.counter = 0
|
||||
self.fail_on = fail_on
|
||||
self.worker_group = backend_executor.get_worker_group()
|
||||
self.results = []
|
||||
|
||||
def handle_result(self, intermiedate_results=None):
|
||||
if intermiedate_results:
|
||||
self.results.append(intermiedate_results)
|
||||
if self.counter == self.fail_on:
|
||||
print("killing")
|
||||
self.results = []
|
||||
ray.kill(self.worker_group.workers[0].actor)
|
||||
time.sleep(3)
|
||||
self.counter += 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
["test", "torch", "tf"] if sys.version_info < (3, 12) else ["test", "torch"],
|
||||
)
|
||||
def test_worker_kill(ray_start_4_cpus, backend):
|
||||
if backend == "test":
|
||||
test_config = BackendConfig()
|
||||
elif backend == "torch":
|
||||
from ray.train.torch import TorchConfig
|
||||
|
||||
test_config = TorchConfig()
|
||||
elif backend == "tf":
|
||||
from ray.train.tensorflow import TensorflowConfig
|
||||
|
||||
test_config = TensorflowConfig()
|
||||
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
train.report(dict(loss=1, iter=i))
|
||||
|
||||
iterator = create_iterator(train_func, test_config)
|
||||
kill_callback = KillCallback(fail_on=0, backend_executor=iterator._backend_executor)
|
||||
|
||||
for intermediate_result in iterator:
|
||||
# Run 1: iter=0, counter=1, Successful
|
||||
# Run 2: iter=1, counter=1, Unsuccessful, starts training from beginning
|
||||
# Run 3: iter=0, counter=2, Successful
|
||||
# Run 4: iter=1, counter=3, Successful
|
||||
kill_callback.handle_result()
|
||||
assert kill_callback.counter == 3
|
||||
|
||||
iterator = create_iterator(train_func, test_config)
|
||||
kill_callback = KillCallback(fail_on=1, backend_executor=iterator._backend_executor)
|
||||
for intermediate_result in iterator:
|
||||
# Run 1: iter=0, counter=1, Successful
|
||||
# Run 2: iter=1, counter=2, Successful
|
||||
# Run 3: None, counter=2, Unsuccessful, starts training from beginning.
|
||||
# Run 4: iter=0, counter=3, Successful
|
||||
# Run 5: iter=1, counter=4, Successful
|
||||
kill_callback.handle_result()
|
||||
assert kill_callback.counter == 4
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not installed in python 3.12+"
|
||||
)
|
||||
def test_tensorflow_mnist_fail(ray_start_4_cpus):
|
||||
"""Tests if tensorflow example works even with worker failure."""
|
||||
epochs = 3
|
||||
num_workers = 2
|
||||
|
||||
from ray.train.examples.tf.tensorflow_mnist_example import (
|
||||
train_func as tensorflow_mnist_train_func,
|
||||
)
|
||||
from ray.train.tensorflow import TensorflowConfig
|
||||
|
||||
test_config = TensorflowConfig()
|
||||
|
||||
train_func = functools.partial(
|
||||
tensorflow_mnist_train_func, {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
)
|
||||
iterator = create_iterator(train_func, test_config, num_workers=num_workers)
|
||||
kill_callback = KillCallback(fail_on=0, backend_executor=iterator._backend_executor)
|
||||
|
||||
for intermediate_result in iterator:
|
||||
assert len(intermediate_result) == num_workers
|
||||
kill_callback.handle_result(intermediate_result)
|
||||
|
||||
results = kill_callback.results
|
||||
assert len(results) == epochs
|
||||
last_iter_result = results[-1][0].metrics
|
||||
first_iter_result = results[0][0].metrics
|
||||
|
||||
assert last_iter_result["loss"] < first_iter_result["loss"]
|
||||
assert last_iter_result["accuracy"] > first_iter_result["accuracy"]
|
||||
|
||||
|
||||
def test_torch_linear_failure(ray_start_4_cpus):
|
||||
num_workers = 2
|
||||
epochs = 3
|
||||
|
||||
from ray.train.torch import TorchConfig
|
||||
|
||||
test_config = TorchConfig()
|
||||
|
||||
train_func = functools.partial(
|
||||
linear_train_func, {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
)
|
||||
|
||||
iterator = create_iterator(train_func, test_config, num_workers=num_workers)
|
||||
kill_callback = KillCallback(fail_on=1, backend_executor=iterator._backend_executor)
|
||||
|
||||
for intermediate_result in iterator:
|
||||
assert len(intermediate_result) == num_workers
|
||||
kill_callback.handle_result(intermediate_result)
|
||||
|
||||
results = kill_callback.results
|
||||
assert len(results) == epochs
|
||||
for i in range(num_workers):
|
||||
last_result = results[-1][i].metrics
|
||||
first_result = results[0][i].metrics
|
||||
assert last_result["loss"] < first_result["loss"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(sys.argv[1:] + ["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,331 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray.air.constants import TRAINING_ITERATION
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.examples.pytorch.torch_fashion_mnist_example import (
|
||||
train_func_per_worker as fashion_mnist_train_func,
|
||||
)
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.tune.tune_config import TuneConfig
|
||||
from ray.tune.tuner import Tuner
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_8_cpus():
|
||||
address_info = ray.init(num_cpus=8)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
class TestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return TestBackend
|
||||
|
||||
|
||||
class TestBackend(Backend):
|
||||
def on_start(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
def on_shutdown(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
|
||||
def torch_fashion_mnist(num_workers, use_gpu, num_samples):
|
||||
trainer = TorchTrainer(
|
||||
fashion_mnist_train_func,
|
||||
scaling_config=train.ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={
|
||||
"train_loop_config": {
|
||||
"lr": tune.loguniform(1e-4, 1e-1),
|
||||
"batch_size_per_worker": tune.choice([32, 64, 128]),
|
||||
"epochs": 2,
|
||||
}
|
||||
},
|
||||
tune_config=TuneConfig(
|
||||
num_samples=num_samples,
|
||||
),
|
||||
)
|
||||
analysis = tuner.fit()._experiment_analysis
|
||||
|
||||
# Check that loss decreases in each trial.
|
||||
for df in analysis.trial_dataframes.values():
|
||||
assert df.loc[1, "loss"] < df.loc[0, "loss"]
|
||||
|
||||
|
||||
def test_tune_torch_fashion_mnist(ray_start_8_cpus):
|
||||
torch_fashion_mnist(num_workers=2, use_gpu=False, num_samples=2)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not installed in python 3.12+"
|
||||
)
|
||||
def tune_tensorflow_mnist(num_workers, use_gpu, num_samples):
|
||||
from ray.train.examples.tf.tensorflow_mnist_example import (
|
||||
train_func as tensorflow_mnist_train_func,
|
||||
)
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
trainer = TensorflowTrainer(
|
||||
tensorflow_mnist_train_func,
|
||||
scaling_config=train.ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={
|
||||
"train_loop_config": {
|
||||
"lr": tune.loguniform(1e-4, 1e-1),
|
||||
"batch_size": tune.choice([32, 64, 128]),
|
||||
"epochs": 2,
|
||||
}
|
||||
},
|
||||
tune_config=TuneConfig(
|
||||
num_samples=num_samples,
|
||||
),
|
||||
)
|
||||
analysis = tuner.fit()._experiment_analysis
|
||||
|
||||
# Check that loss decreases in each trial.
|
||||
for df in analysis.trial_dataframes.values():
|
||||
assert df.loc[1, "loss"] < df.loc[0, "loss"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not installed in python 3.12+"
|
||||
)
|
||||
def test_tune_tensorflow_mnist(ray_start_8_cpus):
|
||||
tune_tensorflow_mnist(num_workers=2, use_gpu=False, num_samples=2)
|
||||
|
||||
|
||||
def test_tune_error(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
raise RuntimeError("Error in training function!")
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
)
|
||||
|
||||
result_grid = tuner.fit()
|
||||
with pytest.raises(RuntimeError):
|
||||
raise result_grid[0].error
|
||||
|
||||
|
||||
def test_tune_checkpoint(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for i in range(9):
|
||||
train.report(dict(test=i))
|
||||
with create_dict_checkpoint(dict(hello="world")) as checkpoint:
|
||||
train.report(dict(test=i + 1), checkpoint=checkpoint)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={"train_loop_config": {"max_iter": 5}},
|
||||
)
|
||||
|
||||
result_grid = tuner.fit()
|
||||
assert len(result_grid) == 1
|
||||
result = result_grid[0]
|
||||
assert result.checkpoint
|
||||
assert load_dict_checkpoint(result.checkpoint)["hello"] == "world"
|
||||
|
||||
|
||||
def test_reuse_checkpoint(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
itr = 0
|
||||
ckpt = train.get_checkpoint()
|
||||
if ckpt is not None:
|
||||
ckpt = load_dict_checkpoint(ckpt)
|
||||
itr = ckpt["iter"] + 1
|
||||
|
||||
for i in range(itr, config["max_iter"]):
|
||||
with create_dict_checkpoint(dict(iter=i)) as checkpoint:
|
||||
train.report(dict(test=i, training_iteration=i), checkpoint=checkpoint)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={"train_loop_config": {"max_iter": 5}},
|
||||
)
|
||||
result_grid = tuner.fit()
|
||||
assert len(result_grid) == 1
|
||||
result = result_grid[0]
|
||||
assert result.checkpoint
|
||||
assert load_dict_checkpoint(result.checkpoint)["iter"] == 4
|
||||
|
||||
tuner = Tuner.restore(result_grid.experiment_path, trainable=trainer)
|
||||
result_grid = tuner.fit()
|
||||
assert len(result_grid) == 1
|
||||
assert len(result_grid[0].metrics_dataframe) == 5
|
||||
|
||||
|
||||
def test_retry_with_max_failures(ray_start_4_cpus):
|
||||
"""Tests trainer retry with max_failures > 0 when integrating with Tune."""
|
||||
|
||||
def train_func():
|
||||
ckpt = train.get_checkpoint()
|
||||
restored = bool(ckpt) # Does a previous checkpoint exist?
|
||||
itr = 0
|
||||
if ckpt:
|
||||
ckpt = load_dict_checkpoint(ckpt)
|
||||
itr = ckpt["iter"] + 1
|
||||
|
||||
for i in range(itr, 4):
|
||||
if i == 2 and not restored:
|
||||
raise Exception("try to fail me")
|
||||
with create_dict_checkpoint(dict(iter=i)) as checkpoint:
|
||||
train.report(dict(test=i, training_iteration=i), checkpoint=checkpoint)
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
run_config=tune.RunConfig(failure_config=tune.FailureConfig(max_failures=3)),
|
||||
)
|
||||
|
||||
result_grid = tuner.fit()
|
||||
checkpoint = load_dict_checkpoint(result_grid[0].checkpoint)
|
||||
assert checkpoint["iter"] == 3
|
||||
df = result_grid[0].metrics_dataframe
|
||||
assert len(df[TRAINING_ITERATION]) == 4
|
||||
|
||||
|
||||
def test_restore_with_new_trainer(ray_start_4_cpus, tmpdir, propagate_logs, caplog):
|
||||
def train_func(config):
|
||||
raise RuntimeError("failing!")
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
run_config=train.RunConfig(
|
||||
name="restore_new_trainer", storage_path=str(tmpdir)
|
||||
),
|
||||
datasets={"train": ray.data.from_items([{"a": i} for i in range(10)])},
|
||||
)
|
||||
results = Tuner(trainer).fit()
|
||||
assert results.errors
|
||||
|
||||
def train_func(config):
|
||||
dataset = train.get_dataset_shard("train")
|
||||
assert train.get_context().get_world_size() == 2
|
||||
rows = 0
|
||||
for _ in dataset.iter_rows():
|
||||
rows += 1
|
||||
assert rows == 10
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
# Training function can be modified
|
||||
train_func,
|
||||
backend_config=TestConfig(),
|
||||
# ScalingConfig can be modified
|
||||
scaling_config=train.ScalingConfig(num_workers=2),
|
||||
# New RunConfig will be ignored
|
||||
run_config=train.RunConfig(name="ignored"),
|
||||
# Datasets and preprocessors can be re-specified
|
||||
datasets={"train": ray.data.from_items([{"a": i} for i in range(20)])},
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING, logger="ray.tune.impl.tuner_internal"):
|
||||
tuner = Tuner.restore(
|
||||
str(tmpdir / "restore_new_trainer"),
|
||||
trainable=trainer,
|
||||
resume_errored=True,
|
||||
)
|
||||
assert "they will be ignored in the resumed run" in caplog.text
|
||||
|
||||
results = tuner.fit()
|
||||
assert not results.errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("in_trainer", [True, False])
|
||||
@pytest.mark.parametrize("in_tuner", [True, False])
|
||||
def test_run_config_in_trainer_and_tuner(
|
||||
propagate_logs, tmp_path, caplog, in_trainer, in_tuner
|
||||
):
|
||||
trainer_run_config = (
|
||||
train.RunConfig(name="trainer", storage_path=str(tmp_path))
|
||||
if in_trainer
|
||||
else None
|
||||
)
|
||||
tuner_run_config = (
|
||||
tune.RunConfig(name="tuner", storage_path=str(tmp_path)) if in_tuner else None
|
||||
)
|
||||
trainer = DataParallelTrainer(
|
||||
lambda config: None,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
run_config=trainer_run_config,
|
||||
)
|
||||
with caplog.at_level(logging.INFO, logger="ray.tune.impl.tuner_internal"):
|
||||
tuner = Tuner(trainer, run_config=tuner_run_config)
|
||||
|
||||
both_msg = (
|
||||
"`RunConfig` was passed to both the `Tuner` and the `DataParallelTrainer`"
|
||||
)
|
||||
run_config = tuner._local_tuner.get_run_config()
|
||||
if in_trainer and in_tuner:
|
||||
assert run_config.name == "tuner"
|
||||
assert both_msg in caplog.text
|
||||
elif in_trainer and not in_tuner:
|
||||
assert run_config.name == "trainer"
|
||||
assert both_msg not in caplog.text
|
||||
elif not in_trainer and in_tuner:
|
||||
assert run_config.name == "tuner"
|
||||
assert both_msg not in caplog.text
|
||||
else:
|
||||
assert both_msg not in caplog.text
|
||||
|
||||
|
||||
def test_run_config_in_param_space():
|
||||
trainer = DataParallelTrainer(
|
||||
lambda config: None,
|
||||
backend_config=TestConfig(),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
Tuner(trainer, param_space={"run_config": train.RunConfig(name="ignored")})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""This is a very minimal set of windows tests for Train/Tune."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train.tests.util import create_dict_checkpoint
|
||||
from ray.train.v2.api.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chdir_tmpdir(tmp_path):
|
||||
original_path = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
yield
|
||||
os.chdir(original_path)
|
||||
|
||||
|
||||
def test_storage_path(ray_start_4_cpus, chdir_tmpdir):
|
||||
"""Tests that Train with a local storage path works on Windows."""
|
||||
|
||||
def train_fn(config):
|
||||
for i in range(5):
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
with create_dict_checkpoint({"dummy": "data"}) as checkpoint:
|
||||
train.report({"loss": i}, checkpoint=checkpoint)
|
||||
else:
|
||||
train.report({"loss": i})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_fn,
|
||||
scaling_config=train.ScalingConfig(num_workers=2),
|
||||
run_config=train.RunConfig(storage_path=os.getcwd()),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,352 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.train._internal.worker_group import Worker, WorkerGroup, WorkerMetadata
|
||||
from ray.util.state import list_actors
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus_and_gpus():
|
||||
address_info = ray.init(num_cpus=2, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus_and_neuron_core_accelerator():
|
||||
address_info = ray.init(num_cpus=2, resources={ray_constants.NEURON_CORES: 2})
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus_and_10kb_memory():
|
||||
address_info = ray.init(num_cpus=2, _memory=10_000)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_5_nodes_with_memory():
|
||||
cluster = Cluster()
|
||||
for _ in range(4):
|
||||
cluster.add_node(num_cpus=4, memory=500)
|
||||
cluster.add_node(num_cpus=4, memory=2_000)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def test_worker_creation(ray_start_2_cpus):
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
assert len(wg.workers) == 2
|
||||
time.sleep(1)
|
||||
# Make sure both CPUs are being used by the actors.
|
||||
assert "CPU" not in ray.available_resources()
|
||||
wg.shutdown()
|
||||
|
||||
|
||||
def test_worker_creation_num_cpus(ray_start_2_cpus):
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
wg = WorkerGroup(resources_per_worker={"CPU": 2})
|
||||
time.sleep(1)
|
||||
assert len(wg.workers) == 1
|
||||
# Make sure both CPUs are being used by the actor.
|
||||
assert "CPU" not in ray.available_resources()
|
||||
wg.shutdown()
|
||||
|
||||
|
||||
def test_worker_creation_with_memory(ray_start_5_nodes_with_memory):
|
||||
resources_per_worker = {"memory": 1_000}
|
||||
wg = WorkerGroup(num_workers=2, resources_per_worker=resources_per_worker)
|
||||
assert len(wg.workers) == 2
|
||||
|
||||
nodes = ray.nodes()
|
||||
large_node = [node for node in nodes if node["Resources"]["memory"] == 2_000][0]
|
||||
large_node_id = large_node["NodeID"]
|
||||
|
||||
def validate_scheduling():
|
||||
resources = ray.get_runtime_context().get_assigned_resources()
|
||||
assert resources == resources_per_worker, "Resources should include memory."
|
||||
|
||||
node_id = ray.get_runtime_context().get_node_id()
|
||||
assert (
|
||||
node_id == large_node_id
|
||||
), "Workers should be scheduled on the large node."
|
||||
|
||||
wg.execute(validate_scheduling)
|
||||
|
||||
|
||||
def test_worker_shutdown(ray_start_2_cpus):
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
time.sleep(1)
|
||||
assert "CPU" not in ray.available_resources()
|
||||
assert len(list_actors()) == 2
|
||||
wg.shutdown()
|
||||
time.sleep(1)
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
wg.execute(lambda: 1)
|
||||
|
||||
|
||||
def test_worker_restart(ray_start_2_cpus):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
with pytest.raises(RuntimeError):
|
||||
wg.start()
|
||||
# Avoid race condition.
|
||||
time.sleep(1)
|
||||
wg.shutdown(0)
|
||||
wg.start()
|
||||
wg.execute(lambda: 1)
|
||||
|
||||
|
||||
def test_worker_with_gpu_ids(ray_start_2_cpus_and_gpus):
|
||||
num_gpus = 2
|
||||
wg = WorkerGroup(num_workers=2, resources_per_worker={"GPU": 1})
|
||||
assert len(wg.workers) == 2
|
||||
time.sleep(1)
|
||||
assert ray_constants.GPU not in ray.available_resources()
|
||||
wg.execute(lambda: 1)
|
||||
assert len(wg.workers) == 2
|
||||
for w in wg.workers:
|
||||
resource_ids = w.metadata.resource_ids
|
||||
gpu_ids = resource_ids[ray_constants.GPU]
|
||||
for gpu_id in gpu_ids:
|
||||
assert gpu_id in [str(i) for i in range(num_gpus)]
|
||||
assert len(resource_ids[ray_constants.NEURON_CORES]) == 0
|
||||
|
||||
|
||||
def test_worker_with_neuron_core_accelerator_ids(
|
||||
ray_start_2_cpus_and_neuron_core_accelerator,
|
||||
):
|
||||
num_nc = 2
|
||||
wg = WorkerGroup(
|
||||
num_workers=2, resources_per_worker={ray_constants.NEURON_CORES: 1}
|
||||
)
|
||||
assert len(wg.workers) == 2
|
||||
time.sleep(1)
|
||||
assert ray_constants.NEURON_CORES not in ray.available_resources()
|
||||
wg.execute(lambda: 1)
|
||||
assert len(wg.workers) == 2
|
||||
for w in wg.workers:
|
||||
resource_ids = w.metadata.resource_ids
|
||||
assert len(resource_ids[ray_constants.GPU]) == 0
|
||||
neuron_core_ids = resource_ids[ray_constants.NEURON_CORES]
|
||||
for neuron_core_id in neuron_core_ids:
|
||||
assert neuron_core_id in [str(i) for i in range(num_nc)]
|
||||
|
||||
|
||||
def test_execute_async(ray_start_2_cpus):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
futures = wg.execute_async(lambda: 1)
|
||||
assert len(futures) == 2
|
||||
outputs = ray.get(futures)
|
||||
assert all(o == 1 for o in outputs)
|
||||
|
||||
|
||||
def test_execute(ray_start_2_cpus):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
outputs = wg.execute(lambda: 1)
|
||||
assert len(outputs) == 2
|
||||
assert all(o == 1 for o in outputs)
|
||||
|
||||
|
||||
def test_execute_args(ray_start_2_cpus):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
outputs = wg.execute(lambda x: x, 1)
|
||||
assert len(outputs) == 2
|
||||
assert all(o == 1 for o in outputs)
|
||||
|
||||
|
||||
def test_group_workers_by_node_id(ray_start_2_cpus):
|
||||
def create_worker_group(node_ids):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
wg.workers = [
|
||||
Worker(
|
||||
actor=None,
|
||||
metadata=WorkerMetadata(
|
||||
node_id=node_id,
|
||||
node_ip="dummy",
|
||||
hostname="dummy",
|
||||
resource_ids={},
|
||||
pid=0,
|
||||
),
|
||||
)
|
||||
for node_id in node_ids
|
||||
]
|
||||
return wg
|
||||
|
||||
wg = create_worker_group(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"])
|
||||
wg.sort_workers_by_node_id_and_gpu_id()
|
||||
expected = ["2", "2", "2", "3", "3", "3", "1", "1", "4", "4"]
|
||||
node_ids = [w.metadata.node_id for w in wg.workers]
|
||||
assert node_ids == expected, (
|
||||
"Workers should be grouped by Node ID "
|
||||
"and follow the same original order of IDs encountered (2, 3, 1, 4)."
|
||||
)
|
||||
|
||||
wg = create_worker_group(["2", "3", "1", "4", "2", "1", "3", "3", "4", "2"])
|
||||
wg.sort_workers_by_node_id_and_gpu_id(_first_node_id="1")
|
||||
expected = ["1", "1", "2", "2", "2", "3", "3", "3", "4", "4"]
|
||||
node_ids = [w.metadata.node_id for w in wg.workers]
|
||||
assert (
|
||||
node_ids == expected
|
||||
), "Workers should be grouped by Node ID, with the first ID being 1."
|
||||
|
||||
|
||||
def test_sort_local_workers_by_gpu_id(ray_start_2_cpus):
|
||||
def create_worker_group(pids, node_ids, gpu_ids):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
wg.workers = [
|
||||
Worker(
|
||||
actor=None,
|
||||
metadata=WorkerMetadata(
|
||||
node_id=node_id,
|
||||
node_ip="dummy",
|
||||
hostname="dummy",
|
||||
resource_ids={"GPU": gpu_id.split() if gpu_id else []},
|
||||
pid=pid,
|
||||
),
|
||||
)
|
||||
for pid, node_id, gpu_id in zip(pids, node_ids, gpu_ids)
|
||||
]
|
||||
return wg
|
||||
|
||||
def setup_and_check_worker_group(
|
||||
pids: List[int],
|
||||
node_ids: List[str],
|
||||
gpu_ids: List[Optional[str]],
|
||||
expected_local_ranks: Dict[int, int],
|
||||
):
|
||||
"""
|
||||
Create a worker group, group workers by Node ID,
|
||||
and check local ranks assignment.
|
||||
|
||||
Args:
|
||||
pids: List of unique process IDs.
|
||||
node_ids: List of Node IDs corresponding to each PID.
|
||||
gpu_ids: List of GPU IDs or None for each PID.
|
||||
expected_local_ranks: Dictionary mapping PID to the
|
||||
expected local rank.
|
||||
"""
|
||||
wg = create_worker_group(pids=pids, node_ids=node_ids, gpu_ids=gpu_ids)
|
||||
wg.sort_workers_by_node_id_and_gpu_id()
|
||||
|
||||
# Build local ranks according to the logics in
|
||||
# `BackendExecutor._create_rank_world_size_mappings()`
|
||||
node_id_dict = defaultdict(int)
|
||||
local_ranks_map = defaultdict(int)
|
||||
for w in wg.workers:
|
||||
local_ranks_map[w.metadata.pid] = node_id_dict[w.metadata.node_id]
|
||||
node_id_dict[w.metadata.node_id] += 1
|
||||
|
||||
local_ranks = [local_ranks_map[pid] for pid in pids]
|
||||
|
||||
assert (
|
||||
local_ranks == expected_local_ranks
|
||||
), "Incorrect local ranks allocation!\n"
|
||||
f"Expect: {expected_local_ranks}\nGot: {local_ranks}"
|
||||
|
||||
# Define the worker configurations for different scenarios
|
||||
# For workers without GPU resources, their original order will be preserved
|
||||
cpu_workers_config = {
|
||||
"pids": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"],
|
||||
"gpu_ids": [None] * 8,
|
||||
"expected_local_ranks": [0, 1, 0, 1, 2, 2, 3, 3],
|
||||
}
|
||||
|
||||
gpu_workers_single_gpu_config = {
|
||||
"pids": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"node_ids": ["2", "2", "1", "1", "2", "1", "1", "2"],
|
||||
"gpu_ids": ["1", "0", "3", "2", "2", "0", "1", "3"],
|
||||
"expected_local_ranks": [1, 0, 3, 2, 2, 0, 1, 3],
|
||||
}
|
||||
|
||||
# For workers with multiple gpus, sort by their lowest gpu id
|
||||
gpu_workers_multiple_gpus_config = {
|
||||
"pids": [0, 1, 2, 3],
|
||||
"node_ids": ["2", "1", "1", "2"],
|
||||
"gpu_ids": ["1,3", "2,1", "0,3", "0,2"],
|
||||
"expected_local_ranks": [1, 1, 0, 0],
|
||||
}
|
||||
|
||||
# Setup and check worker groups for each configuration
|
||||
setup_and_check_worker_group(**cpu_workers_config)
|
||||
setup_and_check_worker_group(**gpu_workers_single_gpu_config)
|
||||
setup_and_check_worker_group(**gpu_workers_multiple_gpus_config)
|
||||
|
||||
|
||||
def test_execute_single(ray_start_2_cpus):
|
||||
wg = WorkerGroup(num_workers=2)
|
||||
|
||||
def f():
|
||||
import os
|
||||
|
||||
os.environ["TEST"] = "1"
|
||||
|
||||
wg.execute_single(1, f)
|
||||
|
||||
def check():
|
||||
import os
|
||||
|
||||
return os.environ.get("TEST", "0")
|
||||
|
||||
assert wg.execute(check) == ["0", "1"]
|
||||
|
||||
|
||||
def test_bad_resources(ray_start_2_cpus):
|
||||
with pytest.raises(ValueError):
|
||||
WorkerGroup(num_workers=-1)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
WorkerGroup(resources_per_worker={"CPU": -1})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
WorkerGroup(resources_per_worker={"GPU": -1})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
WorkerGroup(resources_per_worker={"memory": -1})
|
||||
|
||||
|
||||
def test_placement_group(ray_start_2_cpus):
|
||||
"""Tests that workers can be removed and added to a placement group."""
|
||||
num_workers = 2
|
||||
bundle = {"CPU": 1}
|
||||
bundles = [bundle.copy() for _ in range(num_workers)]
|
||||
placement_group = ray.util.placement_group(bundles)
|
||||
wg = WorkerGroup(num_workers=num_workers, placement_group=placement_group)
|
||||
wg.remove_workers([0])
|
||||
wg.add_workers(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,223 @@
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import xgboost as xgb
|
||||
from sklearn.datasets import load_breast_cancer
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
from ray.train.xgboost import RayTrainReportCallback, XGBoostTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_8_cpus():
|
||||
address_info = ray.init(num_cpus=8)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
scale_config = ScalingConfig(num_workers=2)
|
||||
|
||||
data_raw = load_breast_cancer()
|
||||
dataset_df = pd.DataFrame(data_raw["data"], columns=data_raw["feature_names"])
|
||||
dataset_df["target"] = data_raw["target"]
|
||||
train_df, test_df = train_test_split(dataset_df, test_size=0.3)
|
||||
|
||||
params = {
|
||||
"tree_method": "approx",
|
||||
"objective": "binary:logistic",
|
||||
"eval_metric": ["logloss", "error"],
|
||||
}
|
||||
|
||||
|
||||
def test_fit(ray_start_8_cpus):
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = XGBoostTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
class ScalingConfigAssertingXGBoostTrainer(XGBoostTrainer):
|
||||
def training_loop(self) -> None:
|
||||
pgf = train.get_context().get_trial_resources()
|
||||
assert pgf.strategy == "SPREAD"
|
||||
return super().training_loop()
|
||||
|
||||
|
||||
def test_fit_with_advanced_scaling_config(ray_start_8_cpus):
|
||||
"""Ensure that extra ScalingConfig arguments are respected."""
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = ScalingConfigAssertingXGBoostTrainer(
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2,
|
||||
placement_strategy="SPREAD",
|
||||
),
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_resume_from_checkpoint(ray_start_8_cpus, tmpdir):
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = XGBoostTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=5,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
checkpoint = result.checkpoint
|
||||
xgb_model = XGBoostTrainer.get_model(checkpoint)
|
||||
assert xgb_model.num_boosted_rounds() == 5
|
||||
|
||||
trainer = XGBoostTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=10,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
resume_from_checkpoint=result.checkpoint,
|
||||
)
|
||||
result = trainer.fit()
|
||||
model = XGBoostTrainer.get_model(result.checkpoint)
|
||||
assert model.num_boosted_rounds() == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"freq_end_expected",
|
||||
[
|
||||
# With num_boost_round=25 with 0 indexing, the checkpoints will be at:
|
||||
(4, True, 7), # 3, 7, 11, 15, 19, 23, 24 (end)
|
||||
(4, False, 6), # 3, 7, 11, 15, 19, 23
|
||||
(5, True, 5), # 4, 9, 14, 19, 24
|
||||
(0, True, 1), # 24 (end)
|
||||
(0, False, 0),
|
||||
],
|
||||
)
|
||||
def test_checkpoint_freq(ray_start_8_cpus, freq_end_expected):
|
||||
freq, end, expected = freq_end_expected
|
||||
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = XGBoostTrainer(
|
||||
run_config=ray.train.RunConfig(
|
||||
checkpoint_config=ray.train.CheckpointConfig(
|
||||
checkpoint_frequency=freq, checkpoint_at_end=end
|
||||
)
|
||||
),
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params=params,
|
||||
num_boost_round=25,
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
# Assert number of checkpoints
|
||||
assert len(result.best_checkpoints) == expected, str(
|
||||
[(metrics["training_iteration"], cp) for cp, metrics in result.best_checkpoints]
|
||||
)
|
||||
|
||||
# Assert checkpoint numbers are increasing
|
||||
cp_paths = [cp.path for cp, _ in result.best_checkpoints]
|
||||
assert cp_paths == sorted(cp_paths), str(cp_paths)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [None, 0, 1])
|
||||
def test_checkpoint_only_on_rank0(rank):
|
||||
"""Tests that the callback only reports checkpoints on rank 0,
|
||||
or if the rank is not available (Tune usage)."""
|
||||
callback = RayTrainReportCallback(frequency=2, checkpoint_at_end=True)
|
||||
|
||||
booster = mock.MagicMock()
|
||||
|
||||
with mock.patch("ray.train.get_context") as mock_get_context:
|
||||
mock_context = mock.MagicMock()
|
||||
mock_context.get_world_rank.return_value = rank
|
||||
mock_get_context.return_value = mock_context
|
||||
|
||||
with callback._get_checkpoint(booster) as checkpoint:
|
||||
if rank in (0, None):
|
||||
assert checkpoint
|
||||
else:
|
||||
assert not checkpoint
|
||||
|
||||
|
||||
def test_tune(ray_start_8_cpus):
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
trainer = XGBoostTrainer(
|
||||
scaling_config=scale_config,
|
||||
label_column="target",
|
||||
params={**params, "max_depth": 1},
|
||||
datasets={TRAIN_DATASET_KEY: train_dataset, "valid": valid_dataset},
|
||||
)
|
||||
|
||||
tuner = tune.Tuner(
|
||||
trainer,
|
||||
param_space={"params": {"max_depth": tune.grid_search([2, 4])}},
|
||||
)
|
||||
results = tuner.fit()
|
||||
assert sorted([r.config["params"]["max_depth"] for r in results]) == [2, 4]
|
||||
|
||||
|
||||
def test_validation(ray_start_4_cpus):
|
||||
valid_dataset = ray.data.from_pandas(test_df)
|
||||
with pytest.raises(ValueError, match=TRAIN_DATASET_KEY):
|
||||
XGBoostTrainer(
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
label_column="target",
|
||||
params=params,
|
||||
datasets={"valid": valid_dataset},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="label_column"):
|
||||
XGBoostTrainer(
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
datasets={"train": valid_dataset},
|
||||
)
|
||||
|
||||
|
||||
def test_callback_get_model(tmp_path):
|
||||
custom_filename = "custom.json"
|
||||
|
||||
bst = xgb.train(
|
||||
params,
|
||||
dtrain=xgb.DMatrix(train_df, label=train_df["target"]),
|
||||
num_boost_round=1,
|
||||
)
|
||||
bst.save_model(tmp_path.joinpath(custom_filename).as_posix())
|
||||
checkpoint = train.Checkpoint.from_directory(tmp_path.as_posix())
|
||||
|
||||
RayTrainReportCallback.get_model(checkpoint, filename=custom_filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,52 @@
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Dict, Optional, Type
|
||||
|
||||
import ray.cloudpickle as ray_pickle
|
||||
from ray.train import Checkpoint, SyncConfig
|
||||
from ray.train._internal.storage import StorageContext
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def create_dict_checkpoint(
|
||||
data: Dict[str, Any], checkpoint_cls: Type[Checkpoint] = None
|
||||
) -> Checkpoint:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "data.pkl"), "wb") as f:
|
||||
ray_pickle.dump(data, f)
|
||||
checkpoint_cls = checkpoint_cls or Checkpoint
|
||||
yield checkpoint_cls.from_directory(tmpdir)
|
||||
|
||||
|
||||
def load_dict_checkpoint(checkpoint: Checkpoint) -> Dict[str, Any]:
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
with open(os.path.join(checkpoint_dir, "data.pkl"), "rb") as f:
|
||||
return ray_pickle.load(f)
|
||||
|
||||
|
||||
def mock_storage_context(
|
||||
exp_name: str = "exp_name",
|
||||
storage_path: Optional[str] = None,
|
||||
storage_context_cls: Type = StorageContext,
|
||||
sync_config: Optional[SyncConfig] = None,
|
||||
) -> StorageContext:
|
||||
storage_path = storage_path or tempfile.mkdtemp()
|
||||
exp_name = exp_name
|
||||
trial_name = "trial_name"
|
||||
|
||||
storage = storage_context_cls(
|
||||
storage_path=storage_path,
|
||||
experiment_dir_name=exp_name,
|
||||
trial_dir_name=trial_name,
|
||||
sync_config=sync_config,
|
||||
)
|
||||
# Patch the default /tmp/ray/session_* so we don't require ray
|
||||
# to be initialized in unit tests.
|
||||
session_path = tempfile.mkdtemp()
|
||||
storage._get_session_path = lambda: session_path
|
||||
|
||||
os.makedirs(storage.trial_fs_path, exist_ok=True)
|
||||
os.makedirs(storage.trial_driver_staging_path, exist_ok=True)
|
||||
|
||||
return storage
|
||||
Reference in New Issue
Block a user