chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
import collections
import json
import os
import random
import time
from pathlib import Path
from typing import Dict, List, Optional
import ray
from ray import train, tune
from ray.train.data_parallel_trainer import DataParallelTrainer
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
from ray.tune.experiment import Trial
RUNNER_TYPE = os.environ.get("RUNNER_TYPE", "trainer")
STORAGE_PATH = os.environ.get("STORAGE_PATH", "/tmp/ray_results")
EXP_NAME = os.environ.get("EXP_NAME", "restore_integration_test")
CALLBACK_DUMP_FILE = os.environ.get(
"CALLBACK_DUMP_FILE", "/tmp/callback_dump_file.json"
)
CSV_DATA_FILE = os.environ.get("CSV_DATA_FILE", "/tmp/dummy.csv")
TIME_PER_ITER_S = float(os.environ.get("TIME_PER_ITER_S", "0.5"))
NUM_TRIALS = int(os.environ.get("NUM_TRIALS", "1"))
MAX_CONCURRENT_TRIALS = int(os.environ.get("MAX_CONCURRENT_TRIALS", "2"))
ITERATIONS_PER_TRIAL = int(os.environ.get("ITERATIONS_PER_TRIAL", "64"))
class StatefulCallback(tune.Callback):
def __init__(self):
self._trial_iterations = collections.defaultdict(list)
def on_trial_result(
self,
iteration: int,
trials: List["Trial"],
trial: "Trial",
result: Dict,
**info,
):
self._trial_iterations[trial.trial_id].append(result["training_iteration"])
def on_experiment_end(self, trials: List["Trial"], **info):
# Save callback contents to file
with open(CALLBACK_DUMP_FILE, "w") as f:
json.dump(self.get_state(), f, indent=2)
def get_state(self) -> Optional[Dict]:
return {"trial_iters": self._trial_iterations.copy()}
def set_state(self, state: Dict):
self._trial_iterations = state["trial_iters"]
class StatefulSearcher(tune.search.Searcher):
def __init__(
self,
metric: Optional[str] = None,
mode: Optional[str] = None,
):
super().__init__(metric=metric, mode=mode)
self._trial_count = 0
def suggest(self, trial_id: str) -> Optional[Dict]:
self._trial_count += 1
return {"id": self._trial_count}
def on_trial_complete(
self, trial_id: str, result: Optional[Dict] = None, error: bool = False
) -> None:
pass
def save(self, checkpoint_path: str):
with open(checkpoint_path, "w") as f:
json.dump({"trial_count": self._trial_count}, f)
def restore(self, checkpoint_path: str):
with open(checkpoint_path, "r") as f:
state = json.load(f)
self._trial_count = state["trial_count"]
def train_fn(config: dict, data: Optional[dict] = None):
checkpoint = train.get_checkpoint()
start = load_dict_checkpoint(checkpoint)["iteration"] + 1 if checkpoint else 1
training_started_marker = Path(
os.environ.get("RUN_STARTED_MARKER", "/tmp/does-not-exist")
)
if training_started_marker.exists():
# Multiple workers may be trying to delete the same marker
try:
training_started_marker.unlink()
except FileNotFoundError:
pass
for iteration in range(start, ITERATIONS_PER_TRIAL + 1):
time.sleep(TIME_PER_ITER_S)
with create_dict_checkpoint({"iteration": iteration}) as checkpoint:
train.report({"score": random.random()}, checkpoint=checkpoint)
def tuner(experiment_path: str, run_config: tune.RunConfig) -> tune.ResultGrid:
trainable = tune.with_resources(train_fn, resources={"CPU": 1})
trainable = tune.with_parameters(trainable, data={"dummy_data": [1, 2, 3]})
if tune.Tuner.can_restore(experiment_path):
tuner = tune.Tuner.restore(
experiment_path, trainable=trainable, resume_errored=True
)
else:
tuner = tune.Tuner(
trainable,
run_config=run_config,
tune_config=tune.TuneConfig(
num_samples=8,
max_concurrent_trials=2,
search_alg=StatefulSearcher(),
),
)
result_grid = tuner.fit()
return result_grid
def trainer(experiment_path: str, run_config: train.RunConfig) -> train.Result:
dataset_size = 128
num_workers = 4
def train_loop_per_worker(config):
# Wrap the other train_fn with a check for the dataset.
assert train.get_dataset_shard("train")
train_fn(config)
datasets = {
"train": ray.data.range(dataset_size),
"valid": ray.data.read_csv(CSV_DATA_FILE),
}
if DataParallelTrainer.can_restore(experiment_path):
trainer = DataParallelTrainer.restore(
experiment_path,
datasets=datasets,
train_loop_per_worker=train_loop_per_worker,
)
else:
trainer = DataParallelTrainer(
train_loop_per_worker,
datasets=datasets,
scaling_config=train.ScalingConfig(
num_workers=num_workers, trainer_resources={"CPU": 0}
),
run_config=run_config,
)
result = trainer.fit()
return result
if __name__ == "__main__":
experiment_path = os.path.join(STORAGE_PATH, EXP_NAME)
ray.init()
run_config = train.RunConfig(
storage_path=STORAGE_PATH,
name=EXP_NAME,
checkpoint_config=train.CheckpointConfig(num_to_keep=1),
callbacks=[StatefulCallback()],
)
if RUNNER_TYPE == "tuner":
tuner(experiment_path, run_config)
elif RUNNER_TYPE == "trainer":
trainer(experiment_path, run_config)
else:
raise NotImplementedError(
"`RUNNER_TYPE` environment var must be one of ['tuner', 'trainer']"
)
+23
View File
@@ -0,0 +1,23 @@
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
import copy
import pytest
import ray
from ray.tests.conftest import pytest_runtest_makereport # noqa
@pytest.fixture
def restore_data_context(request):
"""Restore any DataContext changes after the test runs"""
original = copy.deepcopy(ray.data.context.DataContext.get_current())
yield
ray.data.context.DataContext._set_current(original)
@pytest.fixture
def disable_fallback_to_object_extension(request, restore_data_context):
"""Disables fallback to ArrowPythonObjectType"""
ray.data.context.DataContext.get_current().enable_fallback_to_arrow_object_ext_type = (
False
)
@@ -0,0 +1,54 @@
from typing import Optional, Type
import pytest
from ray.air.execution._internal.barrier import Barrier
def _raise(exception_type: Type[Exception] = RuntimeError, msg: Optional[str] = None):
def _raise_exception(*args, **kwargs):
raise exception_type(msg)
return _raise_exception
def test_barrier_max_results():
"""Test the `max_results` attribute.
- Set max_results=10
- Assert that the barrier completion callback is not invoked with num_results<10
- Assert that callback is invoked with num_results=10
- Assert that callback is not invoked again when more events arrive
- Assert that more events can arrive without triggering the callback after resetting
"""
barrier = Barrier(max_results=10, on_completion=_raise(AssertionError))
for i in range(9):
barrier.arrive(i)
assert not barrier.completed
# Will trigger the on_completion callback
with pytest.raises(AssertionError):
barrier.arrive(10)
assert barrier.completed
assert barrier.num_results == 10
# Further events will not trigger callback again
barrier.arrive(11)
barrier.reset()
assert not barrier.completed
# After flushing more events can arrive
barrier.arrive(12)
assert barrier.num_results == 1
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,266 @@
import random
from typing import Any, List, Optional
import pytest
import ray
from ray.air import ResourceRequest
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
from ray.air.execution._internal import Barrier
from ray.air.execution._internal.actor_manager import RayActorManager
from ray.air.execution._internal.tracked_actor import TrackedActor
from ray.exceptions import RayActorError
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@ray.remote
class Actor:
"""Simple actor for testing an execution flow.
This actor can fail in these ways:
1. On init if ``actor_init_kill`` is passed as a kwarg
2. On setup_1() if ``actor_setup_kill`` is passed as a kwarg (RayActorError)
3. On setup_1() if ``actor_setup_fail`` is passed as a kwarg (RayTaskError)
4. On train() if ``actor_train_kill`` is passed as a kwarg (RayTaskError)
5. On train() if ``actor_train_fail`` is passed as a kwarg (RayTaskError)
"""
def __init__(self, **kwargs):
self.kwargs = kwargs
if self.kwargs.get("actor_init_kill"):
raise RuntimeError("INIT")
def get_kwargs(self):
return self.kwargs
def setup_1(self):
if self.kwargs.get("actor_setup_kill"):
raise SystemExit
if self.kwargs.get("actor_setup_fail"):
raise RuntimeError("Setup")
return True
def setup_2(self):
return True
def train(self, value: float) -> float:
if value == 4:
if self.kwargs.get("actor_train_kill"):
# SystemExit will invoke a RayActorError
raise SystemExit
if self.kwargs.get("actor_train_fail"):
# RuntimeError will invoke a RayTaskError
raise RuntimeError("TASK")
return value
class TrainFlow:
"""This is a Ray Train-like execution flow.
- We want to run 4 actors in total ("trials")
- Each actor runs two init functions
- We train all actors in parallel for 10 iterations
- Errors can come up on actor construction, in the init functions,
or during training
- When an actor fails, restart that actor
- When a task fails, stop actor, and restart
"""
def __init__(
self, actor_manager: RayActorManager, errors: Optional[List[str]] = None
):
self._actor_manager = actor_manager
self._finished = False
self._actors_to_run = 4
self._tracked_actors = []
self._actors_stopped = 0
self._actors_to_replace = set()
self._ready_actors = set()
self._training_barrier = Barrier(
max_results=self._actors_to_run,
on_completion=self.training_barrier_completed,
)
self._restart_training = None
self._training_iter = 0
self._results = []
self._errors = errors
def setup_actors(self):
for actor_id in range(self._actors_to_run):
error_kwargs = {}
if self._errors:
error = random.choice(self._errors)
error_kwargs[error] = True
print("Actor", actor_id, "will be failing with", error_kwargs)
tracked_actor = self._actor_manager.add_actor(
cls=Actor,
kwargs={"id": actor_id, **error_kwargs},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=self.actor_started,
on_stop=self.actor_stopped,
on_error=self.actor_error,
)
self._tracked_actors.append(tracked_actor)
def actor_started(self, tracked_actor: TrackedActor):
self._actor_manager.schedule_actor_task(
tracked_actor,
"setup_1",
on_error=self.setup_error,
on_result=self.setup_1_result,
)
def actor_stopped(self, tracked_actor: TrackedActor):
self._ready_actors.discard(tracked_actor)
if tracked_actor in self._actors_to_replace:
self._replace_actor(tracked_actor=tracked_actor)
else:
self._actors_stopped += 1
self._finished = self._actors_stopped >= self._actors_to_run
def actor_error(self, tracked_actor: TrackedActor, exception: Exception):
self._ready_actors.discard(tracked_actor)
self._replace_actor(tracked_actor=tracked_actor)
def _replace_actor(self, tracked_actor: TrackedActor):
actor_index = self._tracked_actors.index(tracked_actor)
replacement_actor = self._actor_manager.add_actor(
cls=Actor,
kwargs={"id": actor_index},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=self.actor_started,
on_stop=self.actor_stopped,
on_error=self.actor_error,
)
self._tracked_actors[actor_index] = replacement_actor
def setup_1_result(self, tracked_actor: TrackedActor, result: Any):
self._actor_manager.schedule_actor_task(
tracked_actor,
"setup_2",
on_error=self.setup_error,
on_result=self.setup_2_result,
)
def setup_2_result(self, tracked_actor: TrackedActor, result: Any):
self._ready_actors.add(tracked_actor)
if len(self._ready_actors) == self._actors_to_run:
self.continue_training()
def setup_error(self, tracked_actor: TrackedActor, exception: Exception):
if isinstance(exception, RayActorError):
return
self._actors_to_replace.add(tracked_actor)
self._actor_manager.remove_actor(tracked_actor)
def continue_training(self):
if self._restart_training:
self._training_iter = self._restart_training
else:
self._training_iter += 1
self._training_barrier.reset()
self._actor_manager.schedule_actor_tasks(
self._tracked_actors,
"train",
args=(self._training_iter,),
on_result=self._training_barrier.arrive,
on_error=self.training_error,
)
def training_barrier_completed(self, barrier: Barrier):
self._results.append([res for _, res in barrier.get_results()])
self._restart_training = None
# If less than 10 epochs, continue training
if self._training_iter < 10:
return self.continue_training()
# Else, training finished
for tracked_actor in self._tracked_actors:
self._actor_manager.remove_actor(tracked_actor)
def training_error(self, tracked_actor: TrackedActor, exception: Exception):
self._restart_training = self._training_iter
if isinstance(exception, RayActorError):
return
self._actors_to_replace.add(tracked_actor)
self._ready_actors.discard(tracked_actor)
self._actor_manager.remove_actor(tracked_actor)
def run(self):
self.setup_actors()
while not self._finished:
self._actor_manager.next()
def get_results(self) -> List[List[float]]:
return self._results
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
@pytest.mark.parametrize(
"errors",
[
None,
"actor_init_kill",
"actor_setup_kill",
"actor_setup_fail",
"actor_train_kill",
"actor_train_fail",
# Chaos - every actor fails somehow, but in different ways
[
"actor_init_kill",
"actor_setup_kill",
"actor_setup_fail",
"actor_train_kill",
"actor_train_fail",
],
],
)
def test_e2e(ray_start_4_cpus, resource_manager_cls, errors):
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
if errors and isinstance(errors, str):
errors = [errors]
flow = TrainFlow(actor_manager=actor_manager, errors=errors)
flow.run()
results = flow.get_results()
assert results == [[i] * 4 for i in range(1, 11)], results
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,227 @@
import random
from collections import defaultdict
from typing import Dict, List, Optional
import pytest
import ray
from ray.air import ResourceRequest
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
from ray.air.execution._internal.actor_manager import RayActorManager
from ray.air.execution._internal.tracked_actor import TrackedActor
from ray.exceptions import RayActorError
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@ray.remote
class Actor:
"""Simple actor for testing an execution flow.
This actor can fail in three ways:
1. On init if ``actor_error_init`` is passed as a kwarg
2. On run() if ``actor_error_task`` is passed as a kwarg (RayActorError)
3. On run() if ``task_error`` is passed as a kwarg (RayTaskError)
"""
def __init__(self, **kwargs):
self.kwargs = kwargs
if self.kwargs.get("actor_error_init"):
raise RuntimeError("INIT")
def get_kwargs(self):
return self.kwargs
def run(self, value: float) -> float:
if value == 2:
if self.kwargs.get("actor_error_task"):
# SystemExit will invoke a RayActorError
raise SystemExit
if self.kwargs.get("task_error"):
# RuntimeError will invoke a RayTaskError
raise RuntimeError("TASK")
return value
class TuneFlow:
"""This is a Ray Tune-like execution flow.
- We want to run 10 actors in total ("trials")
- Each actor collects 11 results sequentially
- We schedule up to 6 actors at the same time
- Every step, we see if we should add any new actors
- Otherwise, we just yield control to the event manager and process events one
by one
- When an actor is started, start training flow
- When a result comes in, schedule next future
- If this is the 11th result, stop actor
- When the last actor is stopped, set state to finished
- When an actor fails, restart
- When a task fails, stop actor, and restart
"""
def __init__(
self, actor_manager: RayActorManager, errors: Optional[List[str]] = None
):
self._actor_manager = actor_manager
self._finished = False
self._actors_to_run = 10
self._actors_started = 0
self._actors_stopped = 0
self._max_pending = 6
self._actor_to_id = {}
self._results = defaultdict(list)
self._errors = errors
def maybe_add_actors(self):
if self._actors_started >= self._actors_to_run:
return
if self._actor_manager.num_pending_actors >= self._max_pending:
return
error_kwargs = {}
if self._errors:
error = random.choice(self._errors)
error_kwargs[error] = True
actor_id = self._actors_started
print("Actor", actor_id, "will be failing with", error_kwargs)
tracked_actor = self._actor_manager.add_actor(
cls=Actor,
kwargs={"id": actor_id, **error_kwargs},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=self.actor_started,
on_stop=self.actor_stopped,
on_error=self.actor_error,
)
self._actor_to_id[tracked_actor] = actor_id
self._actors_started += 1
def actor_started(self, tracked_actor: TrackedActor):
self._actor_manager.schedule_actor_task(
tracked_actor,
"run",
kwargs={"value": 0},
on_error=self.task_error,
on_result=self.task_result,
)
def actor_stopped(self, tracked_actor: TrackedActor):
self._actors_stopped += 1
self._finished = self._actors_stopped >= self._actors_to_run
def actor_error(self, tracked_actor: TrackedActor, exception: Exception):
actor_id = self._actor_to_id.pop(tracked_actor)
replacement_actor = self._actor_manager.add_actor(
cls=Actor,
kwargs={
"id": actor_id,
"actor_error_init": False,
"actor_error_task": False,
"task_error": False,
},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=self.actor_started,
on_stop=self.actor_stopped,
on_error=self.actor_error,
)
self._actor_to_id[replacement_actor] = actor_id
def task_result(self, tracked_actor: TrackedActor, result: float):
actor_id = self._actor_to_id[tracked_actor]
self._results[actor_id].append(result)
if result == 10:
self._actor_manager.remove_actor(tracked_actor)
else:
self._actor_manager.schedule_actor_task(
tracked_actor,
"run",
kwargs={"value": result + 1},
on_result=self.task_result,
on_error=self.task_error,
)
def task_error(self, tracked_actor: TrackedActor, exception: Exception):
if isinstance(exception, RayActorError):
return
self._actors_stopped -= 1 # account for extra stop
self._actor_manager.remove_actor(tracked_actor)
actor_id = self._actor_to_id.pop(tracked_actor)
replacement_actor = self._actor_manager.add_actor(
cls=Actor,
kwargs={
"id": actor_id,
"actor_error_init": False,
"actor_error_task": False,
"task_error": False,
},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=self.actor_started,
on_stop=self.actor_stopped,
on_error=self.actor_error,
)
self._actor_to_id[replacement_actor] = actor_id
def run(self):
while not self._finished:
self.maybe_add_actors()
self._actor_manager.next(timeout=1)
def get_results(self) -> Dict[int, List[float]]:
return self._results
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
@pytest.mark.parametrize(
"errors",
[
None,
"actor_error_init",
"actor_error_task",
"task_error",
# Chaos - every actor fails somehow, but in different ways
["actor_error_init", "actor_error_task", "task_error"],
],
)
def test_e2e(ray_start_4_cpus, resource_manager_cls, errors):
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
if errors and isinstance(errors, str):
errors = [errors]
flow = TuneFlow(actor_manager=actor_manager, errors=errors)
flow.run()
results = flow.get_results()
assert all(res[-1] == 10 for res in results.values()), results
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,224 @@
import time
from typing import Any, Type
import pytest
import ray
from ray.air.execution._internal import Barrier
from ray.air.execution._internal.event_manager import RayEventManager
from ray.exceptions import RayTaskError
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@ray.remote
def succeeding(ret: Any = None) -> Any:
return ret
@ray.remote
def failing(exc: Type[Exception], *args) -> None:
raise exc(*args)
@ray.remote
def sleeping(seconds: int, result: Any) -> Any:
time.sleep(seconds)
return result
def test_track_future_success(ray_start_4_cpus):
"""Schedule a future that return successfully.
Check that the on_result callback was triggered.
"""
event_manager = RayEventManager()
seen = set()
def on_result(result: Any):
seen.add(result)
event_manager.track_future(succeeding.remote("a"), on_result=on_result)
event_manager.wait()
assert "a" in seen
assert not event_manager._tracked_futures
def test_track_future_success_no_callback(ray_start_4_cpus):
"""Schedule a future that return successfully.
Check that passing no callback still succeeds.
"""
event_manager = RayEventManager()
event_manager.track_future(succeeding.remote("a"))
event_manager.wait()
assert not event_manager._tracked_futures
def test_track_future_error(ray_start_4_cpus):
"""Schedule a future that fails.
Check that the on_error callback was triggered.
"""
event_manager = RayEventManager()
seen = set()
class CustomError(RuntimeError):
pass
def on_error(exception: Exception):
seen.add(exception)
event_manager.track_future(failing.remote(CustomError), on_error=on_error)
event_manager.wait()
assert isinstance(seen.pop(), CustomError)
assert not event_manager._tracked_futures
def test_track_future_error_no_callback(ray_start_4_cpus):
"""Schedule a future that fails.
Check that passing no callback raises the original error.
"""
event_manager = RayEventManager()
event_manager.track_future(failing.remote(RuntimeError))
with pytest.raises(RuntimeError):
event_manager.wait()
assert not event_manager._tracked_futures
@pytest.mark.parametrize("results_per_wait", [None, 1, 5, 10, 100])
def test_many_futures(ray_start_4_cpus, results_per_wait):
"""Schedule 500 succeeding and failing futures.
Check that the callbacks get triggered correctly, independent of the number
of results we await per call to RayEventManager.wait().
"""
num_futures = 500
event_manager = RayEventManager()
seen_results = set()
seen_errors = set()
def on_result(result: Any):
seen_results.add(result)
def on_error(exception: RayTaskError):
seen_errors.add(exception.cause.args[0])
for i in range(num_futures):
event_manager.track_futures(
[
succeeding.remote("a" + str(i)),
failing.remote(RuntimeError, "b" + str(i)),
],
on_result=on_result,
on_error=on_error,
)
while event_manager.num_futures > 0:
event_manager.wait(num_results=results_per_wait)
for i in range(num_futures):
assert "a" + str(i) in seen_results
assert "b" + str(i) in seen_errors
def test_timeout(ray_start_4_cpus):
"""Test the timeout parameter.
Start 4 tasks: Two succeed immediately, two after 1 second.
After waiting for 0.5 seconds, the first two tasks should have returned.
After waiting for up to 5 seconds, the other two tasks should have returned.
But because the tasks take only 0.5 seconds to run, we should have waited
way less than 5 seconds.
"""
event_manager = RayEventManager()
seen = set()
def on_result(result: Any):
seen.add(result)
event_manager.track_futures(
[
succeeding.remote("a"),
succeeding.remote("b"),
sleeping.remote(1, "c"),
sleeping.remote(1, "d"),
],
on_result=on_result,
)
start = time.monotonic()
event_manager.wait(num_results=None, timeout=0.5)
assert "a" in seen
assert "b" in seen
assert "c" not in seen
assert "d" not in seen
event_manager.wait(num_results=None, timeout=5)
taken = time.monotonic() - start
assert "c" in seen
assert "d" in seen
# Should have returned much earlier than after 5 seconds
assert taken < 3
assert not event_manager._tracked_futures
def test_task_barrier(ray_start_4_cpus):
event_manager = RayEventManager()
seen = set()
def on_completion(barrier: Barrier):
seen.update(barrier.get_results())
barrier = Barrier(max_results=4, on_completion=on_completion)
event_manager.track_futures(
[
succeeding.remote("a"),
succeeding.remote("b"),
succeeding.remote("c"),
succeeding.remote("d"),
sleeping.remote(2, "e"),
],
on_result=barrier.arrive,
)
event_manager.wait(num_results=4)
assert "a" in seen
assert "b" in seen
assert "c" in seen
assert "d" in seen
assert "e" not in seen
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,178 @@
import pytest
import ray
from ray.air.execution.resources.fixed import FixedResourceManager
from ray.air.execution.resources.request import ResourceRequest
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
REQUEST_2_CPU = ResourceRequest([{"CPU": 2}])
REQUEST_4_CPU = ResourceRequest([{"CPU": 4}])
REQUEST_1_2_CPU = ResourceRequest([{"CPU": 1}, {"CPU": 2}])
REQUEST_0_2_CPU = ResourceRequest([{"CPU": 0}, {"CPU": 2}])
@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()
def test_acquire_return_resources(ray_start_4_cpus):
manager = FixedResourceManager(total_resources={"CPU": 4})
assert not manager.has_resources_ready(REQUEST_2_CPU)
assert not manager.has_resources_ready(REQUEST_4_CPU)
manager.request_resources(REQUEST_2_CPU)
manager.request_resources(REQUEST_4_CPU)
assert manager.has_resources_ready(REQUEST_4_CPU)
ready_2 = manager.acquire_resources(REQUEST_2_CPU)
assert manager.has_resources_ready(REQUEST_2_CPU)
assert not manager.has_resources_ready(REQUEST_4_CPU)
manager.free_resources(ready_2)
assert manager.has_resources_ready(REQUEST_4_CPU)
def test_numerical_error(ray_start_4_cpus):
"""Make sure we don't run into numerical errors when using fractional resources.
Legacy test: test_trial_runner::TrialRunnerTest::testResourceNumericalError
"""
manager = FixedResourceManager(
total_resources={"CPU": 0.99, "GPU": 0.99, "a": 0.99}
)
resource_request = ResourceRequest([{"CPU": 0.33, "GPU": 0.33, "a": 0.33}])
for i in range(3):
manager.request_resources(resource_request)
assert manager.acquire_resources(
resource_request=resource_request
), manager._available_resources
assert manager._available_resources["CPU"] == 0
assert manager._available_resources["GPU"] == 0
assert manager._available_resources["a"] == 0
def test_bind_two_bundles(ray_start_4_cpus):
"""Test that binding two remote objects to a ready resource works.
- Request resources with 2 bundles (1 CPU and 2 CPUs)
- Bind two remote tasks to these bundles, execute
- Assert that resource allocation returns the correct resources: 1 CPU and 2 CPUs
"""
manager = FixedResourceManager()
manager.request_resources(REQUEST_1_2_CPU)
assert manager.has_resources_ready(REQUEST_1_2_CPU)
@ray.remote
def get_assigned_resources():
return ray.get_runtime_context().get_assigned_resources()
acq = manager.acquire_resources(REQUEST_1_2_CPU)
[av1] = acq.annotate_remote_entities([get_assigned_resources])
res1 = ray.get(av1.remote())
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 1
[av1, av2] = acq.annotate_remote_entities(
[get_assigned_resources, get_assigned_resources]
)
res1, res2 = ray.get([av1.remote(), av2.remote()])
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 1
assert sum(v for k, v in res2.items() if k.startswith("CPU")) == 2
def test_bind_empty_head_bundle(ray_start_4_cpus):
"""Test that binding two remote objects to a ready resource works with empty head.
- Request resources with 2 bundles (0 CPU and 2 CPUs)
- Bind two remote tasks to these bundles, execute
- Assert that resource allocation returns the correct resources: 0 CPU and 2 CPUs
"""
manager = FixedResourceManager()
assert REQUEST_0_2_CPU.head_bundle_is_empty
manager.request_resources(REQUEST_0_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_0_2_CPU)
@ray.remote
def get_assigned_resources():
return ray.get_runtime_context().get_assigned_resources()
acq = manager.acquire_resources(REQUEST_0_2_CPU)
[av1] = acq.annotate_remote_entities([get_assigned_resources])
res1 = ray.get(av1.remote())
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 0
[av1, av2] = acq.annotate_remote_entities(
[get_assigned_resources, get_assigned_resources]
)
res1, res2 = ray.get([av1.remote(), av2.remote()])
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 0
assert sum(v for k, v in res2.items() if k.startswith("CPU")) == 2
@pytest.mark.parametrize("strategy", ["STRICT_PACK", "PACK", "SPREAD", "STRICT_SPREAD"])
def test_strategy(ray_start_4_cpus, strategy):
"""The fixed resoure manager does not support STRICT placement strategies."""
manager = FixedResourceManager()
req = ResourceRequest([{"CPU": 2}], strategy=strategy)
if strategy.startswith("STRICT_"):
with pytest.raises(RuntimeError):
manager.request_resources(req)
else:
manager.request_resources(req)
@pytest.mark.parametrize("strategy", ["STRICT_PACK", "PACK", "SPREAD", "STRICT_SPREAD"])
def test_strategy_nested(ray_start_4_cpus, strategy):
"""The fixed resoure manager does not support STRICT_SPREAD within a PG."""
@ray.remote
def nested_test():
manager = FixedResourceManager()
req = ResourceRequest([{"CPU": 2}], strategy=strategy)
if strategy == "STRICT_SPREAD":
with pytest.raises(RuntimeError):
manager.request_resources(req)
else:
manager.request_resources(req)
pg = ray.util.placement_group([{"CPU": 2}])
ray.wait([pg.ready()])
try:
ray.get(
nested_test.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg, placement_group_capture_child_tasks=True
)
).remote()
)
finally:
ray.util.remove_placement_group(pg)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,409 @@
import time
from collections import Counter
import pytest
import ray
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
from ray.air.execution.resources.request import ResourceRequest
REQUEST_2_CPU = ResourceRequest([{"CPU": 2}])
REQUEST_1_2_CPU = ResourceRequest([{"CPU": 1}, {"CPU": 2}])
REQUEST_0_2_CPU = ResourceRequest([{"CPU": 0}, {"CPU": 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 _count_pg_states():
counter = Counter()
for _, pg_info in ray.util.placement_group_table().items():
counter[pg_info["state"]] += 1
return counter
def test_request_cancel_resources(ray_start_4_cpus):
"""Test that canceling a resource request clears the PG futures.
- Create request
- Assert actual PG is created
- Cancel request
- Assert staging future is removed
- Assert actual PG is removed
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert not manager.has_resources_ready(REQUEST_2_CPU)
manager.request_resources(REQUEST_2_CPU)
# Could be pending or created
pg_states = _count_pg_states()
assert pg_states["PENDING"] + pg_states["CREATED"] == 1
assert pg_states["REMOVED"] == 0
assert manager.get_resource_futures()
manager.cancel_resource_request(REQUEST_2_CPU)
assert not manager.get_resource_futures()
pg_states = _count_pg_states()
assert pg_states["PENDING"] + pg_states["CREATED"] == 0
assert pg_states["REMOVED"] == 1
def test_acquire_return_resources(ray_start_4_cpus):
"""Tests that acquiring and returning resources works.
- At the start, no resources should be ready (no PG scheduled)
- Request resources for 2 CPUs
- (wait until they are ready)
- Assert that these 2 CPUs are available to be acquired
- Acquire
- Assert that there are no 2 CPU resources available anymore
- Free resources
- Assert that the 2 CPU resources are still not available (no new request)
- This is also tested in includes test_request_cancel_resources
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert not manager.has_resources_ready(REQUEST_2_CPU)
# Request PG
manager.request_resources(REQUEST_2_CPU)
# Wait until ready
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_2_CPU)
# PG exists
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 1
assert pg_states["REMOVED"] == 0
# Acquire PG
acquired = manager.acquire_resources(REQUEST_2_CPU)
assert not manager.has_resources_ready(REQUEST_2_CPU)
# Free resources
manager.free_resources(acquired)
assert not manager.has_resources_ready(REQUEST_2_CPU)
# PG still exists
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 0
assert pg_states["REMOVED"] == 1
def test_request_pending(ray_start_4_cpus):
"""Test that requesting too many resources leads to pending PGs.
- Cluster of 4 CPUs
- Request 3 PGs a 2 CPUs
- Acquire 2 PGs
- Assert no resources are available anymore
- Return both PGs
- Assert resources are available again
- Cancel request
- Assert no resources are available again
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert not manager.has_resources_ready(REQUEST_2_CPU)
manager.request_resources(REQUEST_2_CPU)
manager.request_resources(REQUEST_2_CPU)
manager.request_resources(REQUEST_2_CPU)
# Wait until some are ready
ray.wait(manager.get_resource_futures(), num_returns=2)
assert manager.has_resources_ready(REQUEST_2_CPU)
assert len(manager.get_resource_futures()) == 1
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 2
assert pg_states["PENDING"] == 1
assert pg_states["REMOVED"] == 0
acq1 = manager.acquire_resources(REQUEST_2_CPU)
acq2 = manager.acquire_resources(REQUEST_2_CPU)
assert not manager.has_resources_ready(REQUEST_2_CPU)
manager.free_resources(acq1)
manager.free_resources(acq2)
# Third PG becomes ready
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_2_CPU)
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 1
assert pg_states["PENDING"] == 0
assert pg_states["REMOVED"] == 2
manager.cancel_resource_request(REQUEST_2_CPU)
assert not manager.has_resources_ready(REQUEST_2_CPU)
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 0
assert pg_states["PENDING"] == 0
assert pg_states["REMOVED"] == 3
def test_acquire_unavailable(ray_start_4_cpus):
"""Test that acquiring resources that are not available returns None.
- Try to acquire
- Assert this does not work
- Request resources
- Wait until ready
- Acquire
- Assert this did work
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert not manager.acquire_resources(REQUEST_2_CPU)
manager.request_resources(REQUEST_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.acquire_resources(REQUEST_2_CPU)
def test_bind_two_bundles(ray_start_4_cpus):
"""Test that binding two remote objects to a ready resource works.
- Request PG with 2 bundles (1 CPU and 2 CPUs)
- Bind two remote tasks to these bundles, execute
- Assert that resource allocation returns the correct resources: 1 CPU and 2 CPUs
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
manager.request_resources(REQUEST_1_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_1_2_CPU)
@ray.remote
def get_assigned_resources():
return ray.get_runtime_context().get_assigned_resources()
acq = manager.acquire_resources(REQUEST_1_2_CPU)
[av1] = acq.annotate_remote_entities([get_assigned_resources])
res1 = ray.get(av1.remote())
assert res1 == {"CPU": 1}
[av1, av2] = acq.annotate_remote_entities(
[get_assigned_resources, get_assigned_resources]
)
res1, res2 = ray.get([av1.remote(), av2.remote()])
assert res1 == {"CPU": 1}
assert res2 == {"CPU": 2}
def test_bind_empty_head_bundle(ray_start_4_cpus):
"""Test that binding two remote objects to a ready resource works with empty head.
- Request PG with 2 bundles (0 CPU and 2 CPUs)
- Bind two remote tasks to these bundles, execute
- Assert that resource allocation returns the correct resources: 0 CPU and 2 CPUs
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert REQUEST_0_2_CPU.head_bundle_is_empty
manager.request_resources(REQUEST_0_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_0_2_CPU)
@ray.remote
def get_assigned_resources():
return ray.get_runtime_context().get_assigned_resources()
acq = manager.acquire_resources(REQUEST_0_2_CPU)
[av1] = acq.annotate_remote_entities([get_assigned_resources])
res1 = ray.get(av1.remote())
assert res1 == {}
[av1, av2] = acq.annotate_remote_entities(
[get_assigned_resources, get_assigned_resources]
)
res1, res2 = ray.get([av1.remote(), av2.remote()])
assert res1 == {}
assert res2 == {"CPU": 2}
def test_capture_child_tasks(ray_start_4_cpus):
"""Test that child tasks are captured when creating placement groups.
- Request PG with 2 bundles (1 CPU and 2 CPUs)
- Bind a remote task that needs 2 CPUs to run
- Assert that it can be scheduled from within the first bundle
This is only the case if child tasks are captured in the placement groups, as
there is only 1 CPU available outside (on a 4 CPU cluster). The 2 CPUs
thus have to come from the placement group.
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
manager.request_resources(REQUEST_1_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_1_2_CPU)
@ray.remote
def needs_cpus():
return "Ok"
@ray.remote
def spawn_child_task(num_cpus: int):
return ray.get(needs_cpus.options(num_cpus=num_cpus).remote())
acq = manager.acquire_resources(REQUEST_1_2_CPU)
[av1] = acq.annotate_remote_entities([spawn_child_task])
res = ray.get(av1.remote(2), timeout=2.0)
assert res
def test_clear_state(ray_start_4_cpus):
"""Test that clearing state will remove existing placement groups.
- Create resource request
- Wait until PG is scheduled
- Assert that Ray PG is created
- Call `mgr.clear()`
- Assert that resources are not ready anymore
- Assert that Ray PG is removed
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
manager.request_resources(REQUEST_1_2_CPU)
ray.wait(manager.get_resource_futures(), num_returns=1)
assert manager.has_resources_ready(REQUEST_1_2_CPU)
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 1
assert pg_states["PENDING"] == 0
assert pg_states["REMOVED"] == 0
manager.clear()
assert not manager.has_resources_ready(REQUEST_1_2_CPU)
pg_states = _count_pg_states()
assert pg_states["CREATED"] == 0
assert pg_states["PENDING"] == 0
assert pg_states["REMOVED"] == 1
def test_internal_state(ray_start_4_cpus):
"""Test internal state mappings of the placement group manager.
This test makes assumptions and assertions around the internal state transition
of private properties of the placement group resource manager.
If you change internal handling logic of the manager, you may need to change this
test as well.
"""
manager = PlacementGroupResourceManager(update_interval_s=0)
assert manager.update_interval_s == 0
manager.has_resources_ready(REQUEST_2_CPU)
# The key may exist but the set should be empty
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
####
# 1. Request, wait until ready, cancel
# Request resources
manager.request_resources(REQUEST_2_CPU)
# PG should be staged
assert manager._request_to_staged_pgs[REQUEST_2_CPU]
pg = list(manager._request_to_staged_pgs[REQUEST_2_CPU])[0]
assert manager._pg_to_request[pg] == REQUEST_2_CPU
# Staging future should exist
assert manager._pg_to_staging_future[pg]
fut = manager._pg_to_staging_future[pg]
assert manager._staging_future_to_pg[fut] == pg
# Wait until PG is ready
while not manager.has_resources_ready(resource_request=REQUEST_2_CPU):
time.sleep(0.05)
# PG should now be ready
assert manager._request_to_ready_pgs[REQUEST_2_CPU]
# PG should not be staged anymore
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
# Staging future should not exist anymore
assert not manager._pg_to_staging_future
assert not manager._staging_future_to_pg
# Cancel request
manager.cancel_resource_request(REQUEST_2_CPU)
# PG should not be ready anymore
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
# All PGs should be fully removed
assert not manager._pg_to_request
####
# 2. Request, cancel while staging
# Stage another PG
manager.request_resources(REQUEST_2_CPU)
# Cancel request before it's ready
manager.cancel_resource_request(REQUEST_2_CPU)
# Assert no leftover
assert not manager._pg_to_staging_future
assert not manager._staging_future_to_pg
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
assert not manager._pg_to_request
####
# 2. Request, acquire, free
# Stage another PG
manager.request_resources(REQUEST_2_CPU)
pg = list(manager._request_to_staged_pgs[REQUEST_2_CPU])[0]
# Wait until PG is ready
while not manager.has_resources_ready(resource_request=REQUEST_2_CPU):
time.sleep(0.05)
# Acquire
acquired_resources = manager.acquire_resources(resource_request=REQUEST_2_CPU)
# Assert no staging/ready leftover
assert not manager._pg_to_staging_future
assert not manager._staging_future_to_pg
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
# We still retain this mapping
assert manager._pg_to_request
# And we keep track of acquired PGs
assert pg in manager._acquired_pgs
# Free PG
manager.free_resources(acquired_resources)
# State should be cleared now
assert not manager._pg_to_request
assert not manager._acquired_pgs
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,38 @@
import pytest
from ray.air.execution.resources.request import ResourceRequest
def test_request_same():
"""Test that resource requests are the same if they share the same properties."""
assert ResourceRequest([{"CPU": 1}]) == ResourceRequest([{"CPU": 1}])
# multiple bundles work
assert ResourceRequest([{"CPU": 1}, {"CPU": 2}]) == ResourceRequest(
[{"CPU": 1}, {"CPU": 2}]
)
# multiple resources work
assert ResourceRequest([{"CPU": 1, "GPU": 1}]) == ResourceRequest(
[{"CPU": 1, "GPU": 1}]
)
# 0 resources are ignored
assert ResourceRequest([{"CPU": 0, "GPU": 1}]) == ResourceRequest([{"GPU": 1}])
# PACK is implicit
assert ResourceRequest([{"CPU": 1}], strategy="PACK") == ResourceRequest(
[{"CPU": 1}]
)
# Non match: different strategy
assert ResourceRequest([{"CPU": 1}], strategy="PACK") != ResourceRequest(
[{"CPU": 1}], strategy="SPREAD"
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,366 @@
import gc
import threading
import time
from collections import Counter
from typing import Any, Optional, Type
import pytest
import ray
from ray.air import ResourceRequest
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
from ray.air.execution._internal import Barrier
from ray.air.execution._internal.actor_manager import RayActorManager
def _raise(exception_type: Type[Exception] = RuntimeError, msg: Optional[str] = None):
def _raise_exception(*args, **kwargs):
raise exception_type(msg)
return _raise_exception
class Started(RuntimeError):
pass
class Stopped(RuntimeError):
pass
class Failed(RuntimeError):
pass
class Result(RuntimeError):
pass
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@pytest.fixture
def cleanup():
# Garbage collect at the start
# This ensures that all resources are freed up for the upcoming test.
gc.collect()
yield
class Actor:
def __init__(self, **kwargs):
self.kwargs = kwargs
def get_kwargs(self):
return self.kwargs
def task(self, value: Any):
return value
@ray.remote(num_cpus=4)
def fn():
return True
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
@pytest.mark.parametrize("actor_cls", [Actor, ray.remote(Actor)])
@pytest.mark.parametrize("kill", [False, True])
def test_start_stop_actor(ray_start_4_cpus, resource_manager_cls, actor_cls, kill):
"""Test that starting and stopping actors work and invokes a callback.
- Start an actor
- Starting should trigger start callback
- Schedule actor task, which should resolve (meaning actor successfully started)
- Stop actor, which should resolve and trigger stop callback
- Schedule remote fn that takes up all cluster resources. This should resolve,
meaning that the actor was stopped successfully.
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
# Start actor, set callbacks
tracked_actor = actor_manager.add_actor(
cls=actor_cls,
kwargs={"key": "val"},
resource_request=ResourceRequest([{"CPU": 4}]),
on_start=_raise(Started),
on_stop=_raise(Stopped),
on_error=_raise(Failed),
)
# Actor should be started
with pytest.raises(Started):
actor_manager.next()
# Schedule task on actor which should resolve (actor successfully started)
actor_manager.schedule_actor_task(
tracked_actor, "task", (1,), on_result=_raise(Result)
)
with pytest.raises(Result):
actor_manager.next()
# Now we can assert that there are no CPUS resources available anymore.
# Note that actor starting is asynchronous, so we can't assert this right away
# - that's why we wait for the actor task to resolve first.
assert ray.available_resources().get("CPU", 0.0) == 0, ray.available_resources()
# Stop actor
actor_manager.remove_actor(tracked_actor, kill=kill)
with pytest.raises(Stopped):
actor_manager.next()
# This task takes up all the cluster resources. It should resolve now that
# the actor was terminated.
assert ray.get(fn.remote(), timeout=5)
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
def test_start_many_actors(ray_start_4_cpus, resource_manager_cls):
"""Test that starting more actors than fit onto the cluster works.
- Request 10 actors
- 4 can be started. Assert they are started
- Stop 2
- Assert 2 are stopped and 2 new ones are started
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
running_actors = []
# stats keeps track of started/stopped actors
stats = Counter()
def start_callback(tracked_actor):
running_actors.append(tracked_actor)
stats["started"] += 1
def stop_callback(tracked_actor):
running_actors.remove(tracked_actor)
stats["stopped"] += 1
# start 10 actors
expected_actors = []
for i in range(10):
tracked_actor = actor_manager.add_actor(
cls=Actor,
kwargs={"key": "val"},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=start_callback,
on_stop=stop_callback,
on_error=_raise(Failed),
)
expected_actors.append(tracked_actor)
# wait for some actor starts
for i in range(4):
actor_manager.next()
# we should now have 4 started actors
assert stats["started"] == 4
assert stats["stopped"] == 0
assert len(running_actors) == 4
assert set(running_actors) == set(expected_actors[:4])
# stop 2 actors
actor_manager.remove_actor(running_actors[0])
actor_manager.remove_actor(running_actors[1])
# Wait four times, twice for termination, twice for start
for i in range(4):
actor_manager.next()
# we should have 4 running actors, 6 started and 2 stopped
assert stats["started"] == 6
assert stats["stopped"] == 2
assert len(running_actors) == 4
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
@pytest.mark.parametrize("where", ["init", "fn"])
def test_actor_fail(ray_start_4_cpus, cleanup, resource_manager_cls, where):
"""Test that actor failures are handled properly.
- Start actor that either fails on init or in a task (RayActorError)
- Schedule task on actor
- Assert that the correct callbacks are called
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
# keep track of failed tasks and actors
stats = Counter()
@ray.remote
class FailingActor:
def __init__(self, where):
self._where = where
if self._where == "init":
raise RuntimeError("INIT")
def fn(self):
if self._where == "fn":
# SystemExit will invoke a RayActorError
raise SystemExit
return True
def fail_callback_actor(tracked_actor, exception):
stats["failed_actor"] += 1
def fail_callback_task(tracked_actor, exception):
stats["failed_task"] += 1
# Start actor
tracked_actor = actor_manager.add_actor(
cls=FailingActor,
kwargs={"where": where},
resource_request=ResourceRequest([{"CPU": 1}]),
on_error=fail_callback_actor,
)
if where != "init":
# Wait until it is started. This won't invoke any callback, yet
actor_manager.next()
assert stats["failed_actor"] == 0
assert stats["failed_task"] == 0
# Schedule task
actor_manager.schedule_actor_task(
tracked_actor, "fn", on_error=fail_callback_task
)
# Yield control and wait for task resolution. This will invoke the callback.
actor_manager.next()
assert stats["failed_actor"] == 1
assert stats["failed_task"] == bool(where != "init")
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
def test_stop_actor_before_start(
ray_start_4_cpus, tmp_path, cleanup, resource_manager_cls
):
"""Test that actor failures are handled properly.
- Start actor that either fails on init or in a task (RayActorError)
- Schedule task on actor
- Assert that the correct callbacks are called
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
hang_marker = tmp_path / "hang.txt"
@ray.remote
class HangingActor:
def __init__(self):
while not hang_marker.exists():
time.sleep(0.05)
tracked_actor = actor_manager.add_actor(
HangingActor,
kwargs={},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=_raise(RuntimeError, "Should not have started"),
on_stop=_raise(RuntimeError, "Should not have stopped"),
)
while not actor_manager.is_actor_started(tracked_actor):
actor_manager.next(0.05)
# Actor started but hasn't triggered on_start, yet
actor_manager.remove_actor(tracked_actor)
hang_marker.write_text("")
while actor_manager.is_actor_started(tracked_actor):
actor_manager.next(0.05)
assert actor_manager.num_live_actors == 0
@pytest.mark.parametrize(
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
)
@pytest.mark.parametrize("start_thread", [False, True])
def test_stop_actor_custom_future(
ray_start_4_cpus, tmp_path, cleanup, resource_manager_cls, start_thread
):
"""If we pass a custom stop future, the actor should still be shutdown by GC.
This should also be the case when we start a thread in the background, as we
do e.g. in Ray Tune's function runner.
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
hang_marker = tmp_path / "hang.txt"
actor_name = f"stopping_actor_{resource_manager_cls.__name__}_{start_thread}"
@ray.remote(name=actor_name)
class HangingStopActor:
def __init__(self):
self._thread = None
self._stop_event = threading.Event()
if start_thread:
def entrypoint():
while True:
print("Thread!")
time.sleep(1)
if self._stop_event.is_set():
sys.exit(0)
self._thread = threading.Thread(target=entrypoint)
self._thread.start()
def stop(self):
print("Waiting")
while not hang_marker.exists():
time.sleep(0.05)
self._stop_event.set()
print("stopped")
start_barrier = Barrier(max_results=1)
stop_barrier = Barrier(max_results=1)
tracked_actor = actor_manager.add_actor(
HangingStopActor,
kwargs={},
resource_request=ResourceRequest([{"CPU": 1}]),
on_start=start_barrier.arrive,
on_stop=stop_barrier.arrive,
)
while not start_barrier.completed:
actor_manager.next(0.05)
# Actor is alive
assert ray.get_actor(actor_name)
stop_future = actor_manager.schedule_actor_task(tracked_actor, "stop")
actor_manager.remove_actor(tracked_actor, kill=False, stop_future=stop_future)
assert not stop_barrier.completed
hang_marker.write_text("!")
while not stop_barrier.completed:
actor_manager.next(0.05)
# Actor should have stopped now and should get cleaned up
with pytest.raises(ValueError):
ray.get_actor(actor_name)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,122 @@
from collections import Counter
import pytest
import ray
from ray.air import ResourceRequest
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
from ray.air.execution._internal.actor_manager import RayActorManager
RESOURCE_MANAGERS = [FixedResourceManager, PlacementGroupResourceManager]
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@ray.remote
class Actor:
def foo(self, val, error: bool = False):
if error:
raise RuntimeError
return val
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
def test_resolve(ray_start_4_cpus, resource_manager_cls):
"""Test that the `on_result` callback is invoked when a task completes.
- Instantiate global data object
- Schedule task that returns a value
- The callback writes the returned value to the global data object
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
seen = {"data": 0}
def result_callback(tracked_actor, result):
seen["data"] = result
tracked_actor = actor_manager.add_actor(
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
)
actor_manager.schedule_actor_task(
tracked_actor, "foo", (4, False), on_result=result_callback
)
actor_manager.next()
actor_manager.next()
assert seen["data"] == 4
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
@pytest.mark.parametrize("num_tasks", [1, 10, 100])
def test_resolve_many(ray_start_4_cpus, resource_manager_cls, num_tasks):
"""Schedule ``num_tasks`` tasks and wait until ``wait_for_events`` of them resolve.
Every resolved task will increase a counter by its return value (1).
"""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
seen = {"data": 0}
def result_callback(tracked_actor, result):
seen["data"] += result
tracked_actor = actor_manager.add_actor(
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
)
actor_manager.next()
for i in range(num_tasks):
actor_manager.schedule_actor_task(
tracked_actor, "foo", (1, False), on_result=result_callback
)
for i in range(num_tasks):
actor_manager.next()
assert seen["data"] == i + 1
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
def test_error_noop(ray_start_4_cpus, resource_manager_cls):
"""When no `on_error` callback is specified, errors should be ignored."""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
tracked_actor = actor_manager.add_actor(
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
)
actor_manager.schedule_actor_task(tracked_actor, "foo", (1, True))
actor_manager.next()
actor_manager.next()
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
def test_error_custom(ray_start_4_cpus, resource_manager_cls):
"""When an `on_error` callback is specified, it is invoked."""
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
stats = Counter()
def error_callback(tracked_actor, exception):
stats["exception"] += 1
tracked_actor = actor_manager.add_actor(
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
)
actor_manager.schedule_actor_task(
tracked_actor, "foo", (1, True), on_error=error_callback
)
actor_manager.next()
actor_manager.next()
assert stats["exception"] == 1
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,136 @@
from collections import namedtuple
from dataclasses import dataclass
from typing import Dict
from unittest.mock import Mock
from wandb.util import json_dumps_safer
import ray
from ray.air.integrations.wandb import WandbLoggerCallback, _WandbLoggingActor
class Trial(
namedtuple(
"MockTrial",
[
"config",
"trial_id",
"trial_name",
"experiment_dir_name",
"placement_group_factory",
"local_path",
],
)
):
def __hash__(self):
return hash(self.trial_id)
def __str__(self):
return self.trial_name
@dataclass
class LoggingActorState:
args: list
kwargs: dict
exclude: list
logs: list
config: dict
class _FakeConfig:
def __init__(self):
self.config = {}
def update(self, config, *args, **kwargs):
self.config.update(config)
class _MockWandbAPI:
"""Thread-safe.
Note: Not implemented to mock re-init behavior properly. Proceed with caution."""
def __init__(self):
self.logs = []
self.config = _FakeConfig()
def init(self, *args, **kwargs):
mock = Mock()
mock.args = args
mock.kwargs = kwargs
if "config" in kwargs:
self.config.update(kwargs["config"])
return mock
def log(self, data, step=None):
try:
json_dumps_safer(data)
except Exception:
self.logs.append("serialization error")
else:
self.logs.append(data)
def finish(self):
pass
def get_logs(self):
return self.logs
def get_config(self):
return self.config.config
class _MockWandbLoggingActor(_WandbLoggingActor):
_mock_wandb_api_cls = _MockWandbAPI
def __init__(self, logdir, queue, exclude, to_config, *args, **kwargs):
super(_MockWandbLoggingActor, self).__init__(
logdir, queue, exclude, to_config, *args, **kwargs
)
self._wandb = self._mock_wandb_api_cls()
def get_state(self):
return LoggingActorState(
args=self.args,
kwargs=self.kwargs,
exclude=self._exclude,
logs=self._wandb.get_logs(),
config=self._wandb.get_config(),
)
class WandbTestExperimentLogger(WandbLoggerCallback):
"""Wandb logger with mocked Wandb API gateway (one per trial)."""
_logger_actor_cls = _MockWandbLoggingActor
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._saved_actor_states: Dict["Trial", LoggingActorState] = {}
def _cleanup_logging_actor(self, trial: "Trial", **kwargs):
logging_actor_state: LoggingActorState = ray.get(
self._trial_logging_actors[trial].get_state.remote()
)
self._saved_actor_states[trial] = logging_actor_state
super()._cleanup_logging_actor(trial, **kwargs)
@property
def trial_logging_actor_states(self) -> Dict["Trial", LoggingActorState]:
return self._saved_actor_states
def get_mock_wandb_logger(mock_api_cls=_MockWandbAPI, **kwargs):
class MockWandbLoggingActor(_MockWandbLoggingActor):
_mock_wandb_api_cls = mock_api_cls
logger = WandbTestExperimentLogger(
project="test_project",
api_key="1234",
**kwargs,
)
logger._logger_actor_cls = MockWandbLoggingActor
return logger
+210
View File
@@ -0,0 +1,210 @@
"""Unit tests for AIR telemetry."""
import json
import os
import sys
from unittest.mock import MagicMock, patch
import pyarrow.fs
import pytest
from packaging.version import Version
import ray
from ray import train, tune
from ray._common.usage.usage_lib import TagKey
from ray.air._internal import usage as air_usage
from ray.air._internal.usage import AirEntrypoint
from ray.air.integrations import comet, mlflow, wandb
from ray.train._internal.storage import StorageContext
from ray.tune.callback import Callback
from ray.tune.experiment.experiment import Experiment
from ray.tune.logger import LoggerCallback
from ray.tune.utils.callback import DEFAULT_CALLBACK_CLASSES
def _mock_record_from_module(module, monkeypatch):
recorded = {}
def mock_record_extra_usage_tag(key: TagKey, value: str):
recorded[key] = value
monkeypatch.setattr(
module,
"record_extra_usage_tag",
mock_record_extra_usage_tag,
)
return recorded
@pytest.fixture
def mock_record(monkeypatch):
import ray.air._internal.usage
yield _mock_record_from_module(ray.air._internal.usage, monkeypatch=monkeypatch)
def train_fn(config):
train.report({"score": 1})
@pytest.fixture
def tuner(tmp_path):
yield tune.Tuner(train_fn, run_config=tune.RunConfig(storage_path=str(tmp_path)))
@pytest.fixture
def trainer(tmp_path):
from ray.train.data_parallel_trainer import DataParallelTrainer
yield DataParallelTrainer(
train_loop_per_worker=train_fn,
scaling_config=train.ScalingConfig(num_workers=2),
run_config=train.RunConfig(storage_path=str(tmp_path)),
)
@pytest.fixture(scope="module")
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
@pytest.mark.parametrize(
"storage_path_filesystem_expected",
[
("/tmp/test", None, "local"),
("s3://", None, "s3"),
("gs://test", None, "gcs"),
("mock://test", None, "mock"),
("test", pyarrow.fs.LocalFileSystem(), "custom"),
],
)
def test_tag_storage_type(storage_path_filesystem_expected, mock_record, monkeypatch):
# Don't write anything to storage for the test.
monkeypatch.setattr(StorageContext, "_create_validation_file", lambda _: None)
monkeypatch.setattr(StorageContext, "_check_validation_file", lambda _: None)
storage_path, storage_filesystem, expected = storage_path_filesystem_expected
if Version(pyarrow.__version__) < Version("17.0.0") and storage_path.startswith(
"gs://"
):
pytest.skip("GCS support requires pyarrow >= 17.0.0")
storage = StorageContext(
storage_path=storage_path,
experiment_dir_name="test",
storage_filesystem=storage_filesystem,
)
air_usage.tag_storage_type(storage)
assert mock_record[TagKey.AIR_STORAGE_CONFIGURATION] == expected
class _CustomLoggerCallback(LoggerCallback):
pass
class _CustomCallback(Callback):
pass
_TEST_CALLBACKS = [
wandb.WandbLoggerCallback,
mlflow.MLflowLoggerCallback,
comet.CometLoggerCallback,
_CustomLoggerCallback,
_CustomLoggerCallback,
_CustomCallback,
]
def test_tag_setup_wandb(mock_record):
from ray.air.integrations.wandb import _setup_wandb
with patch.dict(os.environ, {wandb.WANDB_MODE_ENV_VAR: "disabled"}):
_setup_wandb(trial_id="a", trial_name="b", config={}, _wandb=MagicMock())
assert mock_record[TagKey.AIR_SETUP_WANDB_INTEGRATION_USED] == "1"
def test_tag_setup_mlflow(mock_record, monkeypatch):
from ray.air.integrations.mlflow import setup_mlflow
monkeypatch.setattr(ray.air.integrations.mlflow, "_MLflowLoggerUtil", MagicMock())
setup_mlflow()
assert mock_record[TagKey.AIR_SETUP_MLFLOW_INTEGRATION_USED] == "1"
@pytest.mark.parametrize(
"callback_classes_expected",
[
(None, None),
([], None),
([lambda: None], None),
(
DEFAULT_CALLBACK_CLASSES,
{cls.__name__: 1 for cls in DEFAULT_CALLBACK_CLASSES},
),
(
_TEST_CALLBACKS,
{
"WandbLoggerCallback": 1,
"MLflowLoggerCallback": 1,
"CometLoggerCallback": 1,
"CustomLoggerCallback": 2,
"CustomCallback": 1,
},
),
],
)
def test_tag_callbacks(mock_record, callback_classes_expected):
callback_classes, expected = callback_classes_expected
callbacks = (
[callback_cls() for callback_cls in callback_classes]
if callback_classes
else None
)
air_usage.tag_callbacks(callbacks)
callback_usage_str = mock_record.pop(TagKey.AIR_CALLBACKS, None)
callback_counts = json.loads(callback_usage_str) if callback_usage_str else None
assert callback_counts == expected
def test_tag_env_vars(ray_start_4_cpus, mock_record, tuner):
"""Test that env vars are recorded properly, and arbitrary user environment
variables are ignored."""
env_vars_to_record = {
"TUNE_GLOBAL_CHECKPOINT_S": "20",
"TUNE_MAX_PENDING_TRIALS_PG": "1",
}
untracked_env_vars = {"RANDOM_USER_ENV_VAR": "asdf"}
with patch.dict(os.environ, {**env_vars_to_record, **untracked_env_vars}):
tuner.fit()
recorded_env_vars = json.loads(mock_record[TagKey.AIR_ENV_VARS])
assert sorted(env_vars_to_record) == sorted(recorded_env_vars)
@pytest.mark.parametrize("entrypoint", list(AirEntrypoint))
def test_tag_air_entrypoint(ray_start_4_cpus, mock_record, entrypoint, tuner, trainer):
if entrypoint == AirEntrypoint.TUNE_RUN:
tune.run(train_fn)
elif entrypoint == AirEntrypoint.TUNE_RUN_EXPERIMENTS:
experiment_spec = Experiment("experiment", train_fn)
tune.run_experiments(experiments=experiment_spec)
elif entrypoint == AirEntrypoint.TUNER:
tuner.fit()
elif entrypoint == AirEntrypoint.TRAINER:
trainer.fit()
assert mock_record[TagKey.AIR_ENTRYPOINT] == entrypoint.value
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))
+284
View File
@@ -0,0 +1,284 @@
"""
This test suite covers error handling and propagation in Ray Train/Tune.
There are two main error types to test:
1. Trainable errors: These happen in the remote actor itself.
-> Within this, we should test:
- fail_fast=True/False/'raise'
- AIR Trainer w/o Tuner, AIR Trainer w/ Tuner, Tuner w/ function trainable
2. Tune driver errors: These happen in the Tune event-handling loop.
-> Within this, we should test:
- Errors occurring at different points in the Tune loop
(on_trial_result, on_checkpoint, on_step_begin, etc.)
These tests should:
- Assert how errors from the trainable/Trainer get propagated to the user.
- Assert how errors from the Tune driver get propagated to the user.
"""
import gc
import threading
import time
from tempfile import TemporaryDirectory
import pytest
import ray
from ray import train, tune
from ray._common.test_utils import wait_for_condition
from ray._raylet import GcsClient
from ray.cluster_utils import Cluster
from ray.core.generated import autoscaler_pb2
from ray.tests.conftest import * # noqa
from ray.train.data_parallel_trainer import DataParallelTrainer
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
from ray.train.trainer import BaseTrainer, TrainingFailedError
from ray.tune import TuneError, Tuner
@pytest.fixture
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4, configure_logging=False)
yield address_info
# The code after the yield will run as teardown code.
ray.shutdown()
@pytest.fixture(autouse=True)
def gc_collect():
# Make sure to cleanup as much as possible between
# unit tests that share a Ray session
yield
gc.collect()
@pytest.fixture
def cluster_setup(ray_start_cluster_head: Cluster):
# Sets up a cluster with 3 nodes: head node + 2 workers
cluster = ray_start_cluster_head
nodes = []
nodes.append(cluster.add_node(resources={"worker1": 1, "cpu": 1, "coordinator": 1}))
nodes.append(cluster.add_node(resources={"worker2": 1, "cpu": 1}))
cluster.wait_for_nodes()
@ray.remote
def get_node_id():
return ray.get_runtime_context().get_node_id()
worker1_node_id = ray.get(get_node_id.options(resources={"worker1": 1}).remote())
worker2_node_id = ray.get(get_node_id.options(resources={"worker2": 1}).remote())
wait_for_condition(
lambda: len({node["NodeID"] for node in ray.nodes() if (node["Alive"])}) == 3
)
yield cluster, nodes, [
worker1_node_id,
worker2_node_id,
]
class _TestSpecificError(RuntimeError):
pass
class FailingCallback(tune.Callback):
def __init__(self, error_on: str):
self.error_on = error_on
def on_trial_result(self, *args, **kwargs):
if self.error_on == "on_trial_result":
raise _TestSpecificError(f"Failing on {self.error_on}!")
class FailingTrainer(BaseTrainer):
def training_loop(self) -> None:
raise _TestSpecificError("There is an error in trainer!")
def passing_fn(config):
# Trigger all the driver events (on_checkpoint, on_trial_save, etc.)
with TemporaryDirectory() as tmpdir:
train.report({"score": 1}, checkpoint=train.Checkpoint.from_directory(tmpdir))
def failing_fn(config):
raise _TestSpecificError("Failing!")
trainable_map = {
"function": failing_fn,
"trainer": FailingTrainer(),
}
@pytest.mark.parametrize("fail_fast", [False, True, "raise"])
def test_trainable_error_with_tuner(ray_start_4_cpus, fail_fast):
tuner = Tuner(
trainable=failing_fn,
run_config=tune.RunConfig(
name=f"tuner_errors-fail_fast={fail_fast}",
failure_config=tune.FailureConfig(fail_fast=fail_fast),
),
tune_config=tune.TuneConfig(num_samples=2),
)
if fail_fast is False:
# Both trials should complete with an error.
results = tuner.fit()
assert len(results) == 2
for i in range(2):
assert results[i].error
elif fail_fast is True:
# The first trial errors -> the experiment finishes immediately.
results = tuner.fit()
errors = [result.error for result in results if result.error]
assert len(errors) == 1
elif fail_fast == "raise":
# The original error gets raised to the user
with pytest.raises(_TestSpecificError):
tuner.fit()
@pytest.mark.parametrize("fail_fast", [False, True, "raise"])
def test_trainable_error_with_trainer(ray_start_4_cpus, tmp_path, fail_fast):
name = f"test_trainer_errors-fail_fast={fail_fast}"
trainer = FailingTrainer(
run_config=train.RunConfig(
storage_path=str(tmp_path),
name=name,
failure_config=train.FailureConfig(fail_fast=fail_fast),
),
scaling_config=train.ScalingConfig(num_workers=1),
)
if fail_fast in [False, True]:
# There is only 1 "trial" for a Trainer,
# so fail_fast = True/False doesn't change the behavior
# In both cases, the error should get wrapped and raised.
with pytest.raises(TrainingFailedError) as exc_info:
trainer.fit()
# The cause of the error should be the trainable error
assert isinstance(exc_info.value.__cause__, _TestSpecificError)
assert TrainingFailedError._RESTORE_MSG.format(
trainer_cls_name="FailingTrainer", path=str(tmp_path / name)
) in str(exc_info.value)
assert TrainingFailedError._FAILURE_CONFIG_MSG in str(exc_info.value)
elif fail_fast == "raise":
# The original error gets raised to the user
with pytest.raises(_TestSpecificError):
trainer.fit()
# TODO(ml-team): Test all the driver hooks once driver error propagation is fixed
@pytest.mark.parametrize("error_on", ["on_trial_result"])
def test_driver_error_with_tuner(ray_start_4_cpus, error_on):
tuner = Tuner(
trainable=passing_fn,
run_config=tune.RunConfig(
name=f"test_driver_errors_with_tuner-error_on={error_on}",
callbacks=[FailingCallback(error_on=error_on)],
),
)
# All driver errors should get propagated to the user in the same way
with pytest.raises(TuneError) as exc_info:
tuner.fit()
# TODO(ml-team): Assert the cause error type once driver error propagation is fixed
assert "_TestSpecificError" in str(exc_info.value)
@pytest.mark.parametrize("error_at_level", ["worker", "coordinator"])
def test_preemption_handling(
cluster_setup,
tmp_path,
error_at_level: str,
):
"""Integration test for node preemption handling in Ray Train/Tune.
Even though `max_failures=0`, preemption errors should still be retried."""
cluster, nodes, node_ids = cluster_setup
# node 1 = coordinator and worker, node 2 = worker
coordinator_node, worker_node = nodes
coordinator_node_id, worker_node_id = node_ids
num_workers = 2
tmp_path.joinpath("markers").mkdir()
def train_fn(config):
checkpoint = train.get_checkpoint()
start_iter = 0
if checkpoint:
start_iter = load_dict_checkpoint(checkpoint)["iter"] + 1
print(f"Restored at iter = {start_iter}")
for iter in range(start_iter, 6):
with create_dict_checkpoint({"iter": iter}) as checkpoint:
ray.train.report({"iter": iter}, checkpoint=checkpoint)
if iter == 2:
# Write a "done marker" to tell the driver to simulate a preemption.
tmp_path.joinpath(
"markers", str(ray.train.get_context().get_world_rank())
).touch()
# Await execution.
time.sleep(120)
def launch_training():
trainer = DataParallelTrainer(
train_loop_per_worker=train_fn,
scaling_config=train.ScalingConfig(
num_workers=num_workers,
trainer_resources={"coordinator": 1},
resources_per_worker={"cpu": 1}, # worker2 and worker3
),
run_config=train.RunConfig(
storage_path=str(tmp_path),
name="test_preemption_error",
failure_config=train.FailureConfig(fail_fast=False, max_failures=0),
),
)
result = trainer.fit()
assert result.metrics["iter"] == 5
t = threading.Thread(target=launch_training)
t.start()
# Wait until the workers are ready for preemption (after a few checkpoints).
while len(list(tmp_path.joinpath("markers").glob("*"))) < num_workers:
time.sleep(0.5)
if error_at_level == "coordinator":
node, node_id = coordinator_node, coordinator_node_id
elif error_at_level == "worker":
node, node_id = worker_node, worker_node_id
else:
raise NotImplementedError(f"Invalid error_at_level = {error_at_level}")
# Preempt a node.
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
print("Draining node...")
is_accepted, _ = gcs_client.drain_node(
node_id,
autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"),
"preemption",
0,
)
assert is_accepted
print("Killing node...")
cluster.remove_node(node, allow_graceful=True)
print("Adding new node..") # so that the job can be rescheduled
# New node can replace a preempted coordinator or worker
# NOTE: `cluster.add_node` only works in the main thread.
cluster.add_node(resources={"coordinator": 1, "cpu": 1})
t.join() # Assert no errors during training.
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__] + sys.argv[1:]))
@@ -0,0 +1,232 @@
import json
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from ray.tune.analysis import ExperimentAnalysis
from ray.tune.result_grid import ResultGrid
_RUN_SCRIPT_FILENAME = "_test_experiment_restore_run.py"
def _kill_process_if_needed(
process: subprocess.Popen, timeout_s: float = 10, poll_interval_s: float = 1.0
):
"""Kills a process if it hasn't finished in `timeout_s` seconds.
Polls every `poll_interval_s` seconds to check if the process is still running."""
kill_timeout = time.monotonic() + timeout_s
while process.poll() is None and time.monotonic() < kill_timeout:
time.sleep(poll_interval_s)
if process.poll() is None:
process.terminate()
def _print_message(message):
sep = "=" * 50
print(f"\n{sep}\n{message}\n{sep}\n")
@pytest.mark.parametrize("runner_type", ["tuner", "trainer"])
def test_experiment_restore(tmp_path, runner_type):
"""
This is an integration stress test for experiment restoration.
Test setup:
- For Tuner.restore:
- 8 trials, with a max of 2 running concurrently (--> 4 rounds of trials)
- Each iteration takes 0.5 seconds
- Each trial runs for 8 iterations --> 4 seconds
- Each round of 2 trials should take 4 seconds
- Without any interrupts/restoration:
- Minimum runtime: 4 rounds * 4 seconds / round = 16 seconds
- The test will stop the script with a SIGINT at a random time between
6-10 iterations each restore.
- For Trainer.restore:
- 1 trial with 4 workers
- Each iteration takes 0.5 seconds
- Runs for 32 iterations --> Minimum runtime = 16 seconds
- The test will stop the script with a SIGINT at a random time between
6-10 iterations after each restore.
Requirements:
- Req 1: Training progress persisted
- The experiment should progress monotonically.
(The training iteration shouldn't go backward at any point)
- Trials shouldn't start from scratch.
- Req 2: Searcher state saved/restored correctly
- Req 3: Callback state saved/restored correctly
"""
np.random.seed(2023)
script_path = Path(__file__).parent / _RUN_SCRIPT_FILENAME
# Args to pass into the script as environment variables
exp_name = f"{runner_type}_restore_integration_test"
callback_dump_file = tmp_path / f"{runner_type}-callback_dump_file.json"
storage_path = tmp_path / "ray_results"
if storage_path.exists():
shutil.rmtree(storage_path)
csv_file = str(tmp_path / "dummy_data.csv")
dummy_df = pd.DataFrame({"x": np.arange(128), "y": 2 * np.arange(128)})
dummy_df.to_csv(csv_file)
run_started_marker = tmp_path / "run_started_marker"
time_per_iter_s = 0.5
max_concurrent = 2
if runner_type == "tuner":
iters_per_trial = 8
num_trials = 8
elif runner_type == "trainer":
iters_per_trial = 32
num_trials = 1
total_iters = iters_per_trial * num_trials
env = os.environ.copy()
env.update(
{
"RUNNER_TYPE": runner_type,
"STORAGE_PATH": str(storage_path),
"EXP_NAME": exp_name,
"CALLBACK_DUMP_FILE": str(callback_dump_file),
"RUN_STARTED_MARKER": str(run_started_marker),
"TIME_PER_ITER_S": str(time_per_iter_s),
"ITERATIONS_PER_TRIAL": str(iters_per_trial),
"NUM_TRIALS": str(num_trials),
"MAX_CONCURRENT_TRIALS": str(max_concurrent),
"CSV_DATA_FILE": csv_file,
}
)
# Variables used in the loop
return_code = None
total_runtime = 0
run_iter = 0
progress = 0
progress_history = []
poll_interval_s = 0.1
test_start_time = time.monotonic()
while True:
run_started_marker.write_text("", encoding="utf-8")
run = subprocess.Popen([sys.executable, script_path], env=env)
run_iter += 1
_print_message(f"Started run #{run_iter} w/ PID = {run.pid}")
# Start the timer after the first trial has entered its training loop.
while run.poll() is None and run_started_marker.exists():
time.sleep(poll_interval_s)
# If the run already finished, then exit immediately.
if run.poll() is not None:
return_code = run.poll()
break
timeout_s = np.random.uniform(6 * time_per_iter_s, 10 * time_per_iter_s)
_print_message(
"Training has started...\n"
f"Interrupting after {timeout_s:.2f} seconds\n"
f"Currently at {total_runtime:.2f} seconds"
)
# Sleep for a random amount of time, then stop the run.
start_time = time.monotonic()
time.sleep(timeout_s)
total_runtime += time.monotonic() - start_time
return_code = run.poll()
if return_code is None:
# Send "SIGINT" to stop the run
_print_message(f"Sending SIGUSR1 to run #{run_iter} w/ PID = {run.pid}")
run.send_signal(signal.SIGUSR1)
# Make sure the process is stopped forcefully after a timeout.
_kill_process_if_needed(run)
else:
_print_message("Run has already terminated!")
break
# Check up on the results.
results = ResultGrid(ExperimentAnalysis(str(storage_path / exp_name)))
iters = [result.metrics.get("training_iteration", 0) for result in results]
progress = sum(iters) / total_iters
progress_history.append(progress)
_print_message(
f"Number of trials = {len(results)}\n"
f"% completion = {progress} ({sum(iters)} iters / {total_iters})\n"
f"Currently at {total_runtime:.2f} seconds"
)
_print_message(
f"Total number of restorations = {run_iter}\n"
f"Total runtime = {total_runtime:.2f}\n"
f"Return code = {return_code}"
)
test_end_time = time.monotonic()
assert progress == 1.0
# The script shouldn't have errored. (It should have finished by this point.)
assert return_code == 0, (
f"The script errored with return code: {return_code}.\n"
f"Check the `{_RUN_SCRIPT_FILENAME}` script for any issues. "
)
# Req 1: training progress persisted
# Check that progress increases monotonically (we never go backwards/start from 0)
assert np.all(np.diff(progress_history) >= 0), (
"Expected progress to increase monotonically. Instead, got:\n"
"{progress_history}"
)
# Req 2: searcher state
results = ResultGrid(ExperimentAnalysis(str(storage_path / exp_name)))
# Check that all trials have unique ids assigned by the searcher (if applicable)
ids = [result.config.get("id", -1) for result in results]
ids = [id for id in ids if id >= 0]
if ids:
assert sorted(ids) == list(range(1, num_trials + 1)), (
"Expected the searcher to assign increasing id for each trial, but got:"
f"{ids}"
)
# Req 3: callback state
with open(callback_dump_file, "r") as f:
callback_state = json.load(f)
trial_iters = callback_state["trial_iters"]
for iters in trial_iters.values():
# Check that the callback has data for each trial, for all iters
# NOTE: There may be some duplicate data, due to the fact that
# the callback will be updated on every `on_trial_result` hook,
# but the trial may crash before the corresponding checkpoint gets processed.
assert sorted(set(iters)) == list(
range(1, iters_per_trial + 1)
), f"Expected data from all iterations, but got: {iters}"
_print_message(f"Success! Test took {test_end_time - test_start_time:.2f} seconds.")
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,366 @@
import unittest
from collections import namedtuple
from unittest.mock import patch
from ray.air.integrations.comet import CometLoggerCallback
class MockTrial(
namedtuple("MockTrial", ["config", "trial_name", "trial_id", "logdir"])
):
def __hash__(self):
return hash(self.trial_id)
def __str__(self):
return self.trial_name
class InitializationTests(unittest.TestCase):
def setUp(self):
self.logger = CometLoggerCallback()
def test_class_variable_to_instance(self):
"""Test that class variables get properly assigned to instance
variables.
"""
logger = self.logger
self.assertEqual(logger._to_exclude, logger._exclude_results)
self.assertEqual(logger._to_system, logger._system_results)
self.assertEqual(logger._to_other, logger._other_results)
self.assertEqual(logger._to_episodes, logger._episode_results)
def test_configure_experiment_defaults(self):
"""Test CometLoggerCallback._configure_experiment_defaults."""
logger = self.logger
# Test that autologging features are properly disabled
exclude = CometLoggerCallback._exclude_autolog
for option in exclude:
self.assertFalse(logger.experiment_kwargs.get(option))
del logger
# Don't disable logging if user overwrites defaults by passing in args
for include_option in exclude:
# This unpacks to become e.g. CometLoggerCallback(log_env_cpu=True)
logger = CometLoggerCallback(**{include_option: True})
for option in exclude:
if option == include_option:
self.assertTrue(logger.experiment_kwargs.get(option))
else:
self.assertFalse(logger.experiment_kwargs.get(option))
class HelperMethodTests(unittest.TestCase):
def setUp(self):
self.logger = CometLoggerCallback()
def test_check_key_name(self):
logger = self.logger
# Return True when key == item
self.assertTrue(logger._check_key_name("name", "name"))
# Return True when key.startswith(item + "/")
self.assertTrue(logger._check_key_name("name/", "name"))
# Return False when item.startswith(key + "/")
self.assertFalse(logger._check_key_name("name", "name/"))
# Return False when key != item and not key.startswith(item."/")
self.assertFalse(logger._check_key_name("name", "x"))
@patch("comet_ml.OfflineExperiment")
@patch("comet_ml.Experiment")
class OnlineVsOfflineTests(unittest.TestCase):
def setUp(self):
self.loggers = {
"online": CometLoggerCallback(),
"offline": CometLoggerCallback(online=False),
}
self.trial = MockTrial({"p1": 1}, "trial_1", 1, "artifact")
def test_online_dispatch(self, experiment, offline_experiment):
# To start, there should be no experiments
experiment.assert_not_called()
offline_experiment.assert_not_called()
# Start online experiment
logger = self.loggers["online"]
logger.log_trial_start(self.trial)
# Check that Experiment was called and OfflineExperiment was not
experiment.assert_called_once()
offline_experiment.assert_not_called()
def test_offline_dispatch(self, experiment, offline_experiment):
# To start, there should be no experiments
experiment.assert_not_called()
offline_experiment.assert_not_called()
# Start online experiment
logger = self.loggers["offline"]
logger.log_trial_start(self.trial)
# Check that Experiment was called and OfflineExperiment was not
experiment.assert_not_called()
offline_experiment.assert_called_once()
@patch("comet_ml.OfflineExperiment")
@patch("comet_ml.Experiment")
class LogTrialStartTest(unittest.TestCase):
def setUp(self):
self.loggers = {
"online": CometLoggerCallback(),
"offline": CometLoggerCallback(online=False),
}
self.trials = [
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
MockTrial({"p1": 2}, "trial_2", 1, "artifact"),
]
def test_existing_trialexperiment(self, experiment, offline_experiment):
mocks = {"online": experiment, "offline": offline_experiment}
for option in ["online", "offline"]:
logger = self.loggers[option]
mock = mocks[option]
# This should create an experiment
logger.log_trial_start(self.trials[0])
mock.assert_called_once()
# This should NOT create an experiment because it's the same trial
logger.log_trial_start(self.trials[0])
mock.assert_called_once()
# This should create another new experiment
logger.log_trial_start(self.trials[1])
# Number of times the mock was called
num_calls = len(mock.call_args_list)
# Assert that Experiment/OfflineExperiment was called twice
self.assertEqual(num_calls, 2)
def test_set_global_experiment(self, experiment, offline_experiment):
for option in ["online", "offline"]:
logger = self.loggers[option]
with patch("comet_ml.config.set_global_experiment") as mock:
logger.log_trial_start(self.trials[0])
mock.assert_called_with(None)
mock.assert_called_once()
mock.reset_mock()
def test_experiment_addtags(self, experiment, offline_experiment):
logger = self.loggers["online"]
logger.log_trial_start(self.trials[0])
experiment.return_value.add_tags.assert_called_with(logger.tags)
def test_experiment_setname(self, experiment, offline_experiment):
logger = self.loggers["online"]
trial = self.trials[0]
logger.log_trial_start(trial)
experiment.return_value.set_name.assert_called_with(trial.trial_name)
def test_experiment_logparams(self, experiment, offline_experiment):
logger = self.loggers["online"]
trial = self.trials[0]
logger.log_trial_start(trial)
config = trial.config.copy()
config.pop("callbacks", None)
experiment.return_value.log_parameters.assert_called_with(config)
class ExperimentKwargsTest(unittest.TestCase):
@patch("comet_ml.Experiment")
def test_kwargs_passthrough(self, experiment):
"""Test that additional keyword arguments to CometLoggerCallback get
passed through to comet_ml.Experiment on log_trial_start
"""
experiment_kwargs = {"kwarg_1": "val_1"}
logger = CometLoggerCallback(**experiment_kwargs)
trial = MockTrial({"parameter": 1}, "trial2", 1, "artifact")
logger.log_trial_start(trial)
# These are the default kwargs that get passed to create the experiment
expected_kwargs = {kwarg: False for kwarg in logger._exclude_autolog}
expected_kwargs.update(experiment_kwargs)
experiment.assert_called_with(**expected_kwargs)
@patch("comet_ml.Experiment")
class LogTrialResultTests(unittest.TestCase):
"""
* test log_others logs
* test log_system logs
* test log_curve logs
"""
def setUp(self):
self.logger = CometLoggerCallback()
self.trials = [
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
MockTrial({"p1": 2}, "trial_2", 1, "artifact"),
]
self.result = {
"config": {"p1": 1},
"node_ip": "0.0.0.0",
"hostname": "hostname_val",
"pid": "1234",
"date": "2000-01-01",
"experiment_id": "1234",
"trial_id": 1,
"experiment_tag": "tag1",
"hist_stats/episode_reward": [1, 0, 1, -1, 0, 1],
"hist_stats/episode_lengths": [1, 2, 3, 4, 5, 6],
"metric1": 0.8,
"metric2": 1,
"metric3": None,
"training_iteration": 0,
}
def test_log_parameters(self, experiment):
logger = self.logger
trial = self.trials[0]
result = self.result.copy()
# Check parameters are logged properly.
logger.log_trial_result(1, trial, self.result)
config_update = result.copy().pop("config", {})
config_update.pop("callbacks", None) # Remove callbacks
experiment.return_value.log_parameters.assert_any_call(config_update)
def test_log_metrics(self, experiment):
logger = self.logger
trial = self.trials[0]
result = self.result.copy()
step = result["training_iteration"]
logger.log_trial_result(1, trial, self.result)
result_metrics = {
"metric1": 0.8,
"metric2": 1,
"metric3": None,
"training_iteration": 0,
}
method = experiment.return_value.log_metrics
method.assert_any_call(result_metrics, step=step)
def test_log_other(self, experiment):
logger = self.logger
trial = self.trials[0]
result = self.result.copy()
logger.log_trial_result(1, trial, result)
result_other = {
"experiment_id": "1234",
"trial_id": 1,
"experiment_tag": "tag1",
}
method = experiment.return_value.log_others
# for k,v in result_other.items():
method.assert_any_call(result_other)
def test_log_system(self, experiment):
logger = self.logger
trial = self.trials[0]
result = self.result.copy()
logger.log_trial_result(1, trial, result)
result_system = {
"node_ip": "0.0.0.0",
"hostname": "hostname_val",
"pid": "1234",
"date": "2000-01-01",
}
method = experiment.return_value.log_system_info
for k, v in result_system.items():
method.assert_any_call(k, v)
def test_log_curve(self, experiment):
logger = self.logger
trial = self.trials[0]
# Check parameters are logged properly.
result = self.result
step = result["training_iteration"]
logger.log_trial_result(1, trial, result)
results_curve = {
"hist_stats/episode_reward": [1, 0, 1, -1, 0, 1],
"hist_stats/episode_lengths": [1, 2, 3, 4, 5, 6],
}
method = experiment.return_value.log_curve
print(method.call_args_list)
for k, v in results_curve.items():
method.assert_any_call(k, x=range(len(v)), y=v, step=step)
@patch("comet_ml.Experiment")
class LogTrialEndTests(unittest.TestCase):
def setUp(self):
self.logger = CometLoggerCallback()
self.trials = [
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
MockTrial({"p1": 2}, "trial_2", 2, "artifact"),
MockTrial({"p1": 2}, "trial_3", 3, "artifact"),
]
def test_not_started_exception(self, experiment):
logger = self.logger
with self.assertRaises(KeyError):
logger.log_trial_end(self.trials[0])
def test_repeat_throws_error(self, experiment):
logger = self.logger
trial = self.trials[0]
logger.log_trial_start(trial)
logger.log_trial_end(trial)
with self.assertRaises(KeyError):
logger.log_trial_end(trial)
def test_log_trial_end(self, experiment):
logger = self.logger
trials = self.trials
method = experiment.return_value.end
# Should not have ended yet
method.assert_not_called()
for trial in trials:
logger.log_trial_start(trial)
logger.log_trial_end(trial)
self.assertEqual(len(method.call_args_list), len(trials))
def test_del(self, experiment):
logger = self.logger
for trial in self.trials:
logger.log_trial_start(trial)
end = experiment.return_value.end
end.assert_not_called()
logger.__del__()
self.assertEqual(len(end.call_args_list), len(self.trials))
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,442 @@
import os
import shutil
import sys
import tempfile
import unittest
from collections import namedtuple
from unittest.mock import MagicMock, patch
import pytest
from mlflow.tracking import MlflowClient
import ray
from ray._private.dict import flatten_dict
from ray.air._internal.mlflow import _MLflowLoggerUtil
from ray.air.integrations.mlflow import MLflowLoggerCallback, _NoopModule, setup_mlflow
from ray.train.torch import TorchTrainer
from ray.tune import Tuner
class MockTrial(
namedtuple("MockTrial", ["config", "trial_name", "trial_id", "local_path"])
):
def __hash__(self):
return hash(self.trial_id)
def __str__(self):
return self.trial_name
class Mock_MLflowLoggerUtil(_MLflowLoggerUtil):
def save_artifacts(self, dir, run_id):
self.artifact_saved = True
self.artifact_info = {"dir": dir, "run_id": run_id}
def clear_env_vars():
os.environ.pop("MLFLOW_EXPERIMENT_NAME", None)
os.environ.pop("MLFLOW_EXPERIMENT_ID", None)
@pytest.fixture
def ray_start_4_cpus():
"""Automatically start and stop Ray for each test."""
ray.init(num_cpus=4)
yield
ray.shutdown()
def test_setup_mlflow_in_train_worker(ray_start_4_cpus):
"""Test that setup_mlflow works in a Train worker."""
def train_func(config):
setup_mlflow(
experiment_name="test_exp",
create_experiment_if_not_exists=True,
)
trainer = TorchTrainer(train_func)
trainer.fit()
def test_setup_mlflow_in_tune_trial(ray_start_4_cpus):
"""Test that setup_mlflow works in a Tune trial."""
def train_func(config):
setup_mlflow(
experiment_name="test_exp",
create_experiment_if_not_exists=True,
)
tuner = Tuner(train_func)
result_grid = tuner.fit()
assert all(res.error is None for res in result_grid)
class MLflowTest(unittest.TestCase):
def setUp(self):
self.tracking_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
self.registry_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
client = MlflowClient(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
client.create_experiment(name="existing_experiment")
# Mlflow > 2 creates a "Default" experiment which has ID 0, so we start our
# test with ID 1.
assert client.get_experiment_by_name("existing_experiment").experiment_id == "1"
def tearDown(self) -> None:
pass
def testMlFlowLoggerCallbackConfig(self):
# Explicitly pass in all args.
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri,
registry_uri=self.registry_uri,
experiment_name="test_exp",
)
logger.setup()
self.assertEqual(
logger.mlflow_util._mlflow.get_tracking_uri(), self.tracking_uri
)
self.assertEqual(
logger.mlflow_util._mlflow.get_registry_uri(), self.registry_uri
)
self.assertListEqual(
[e.name for e in logger.mlflow_util._mlflow.search_experiments()],
["test_exp", "existing_experiment", "Default"],
)
self.assertEqual(logger.mlflow_util.experiment_id, "2")
# Check if client recognizes already existing experiment.
logger = MLflowLoggerCallback(
experiment_name="existing_experiment",
tracking_uri=self.tracking_uri,
registry_uri=self.registry_uri,
)
logger.setup()
self.assertEqual(logger.mlflow_util.experiment_id, "1")
# Pass in experiment name as env var.
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
logger.setup()
self.assertEqual(logger.mlflow_util.experiment_id, "2")
# Pass in existing experiment name as env var.
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
logger.setup()
self.assertEqual(logger.mlflow_util.experiment_id, "1")
# Pass in existing experiment id as env var.
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
logger.setup()
self.assertEqual(logger.mlflow_util.experiment_id, "1")
# Pass in non existing experiment id as env var.
# This should create a new experiment.
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_ID"] = "500"
with self.assertRaises(ValueError):
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
logger.setup()
# Experiment id env var should take precedence over name env var.
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
)
logger.setup()
self.assertEqual(logger.mlflow_util.experiment_id, "1")
# Using tags
tags = {"user_name": "John", "git_commit_hash": "abc123"}
clear_env_vars()
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_tags"
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri, tags=tags
)
logger.setup()
self.assertEqual(logger.tags, tags)
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
def testMlFlowLoggerLogging(self):
clear_env_vars()
trial_config = {"par1": "a", "par2": "b"}
trial = MockTrial(trial_config, "trial1", 0, "artifact")
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri,
registry_uri=self.registry_uri,
experiment_name="test1",
save_artifact=True,
tags={"hello": "world"},
)
logger.setup()
# Check if run is created with proper tags.
logger.on_trial_start(iteration=0, trials=[], trial=trial)
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
self.assertEqual(len(all_runs), 1)
# all_runs is a pandas dataframe.
all_runs = all_runs.to_dict(orient="records")
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
self.assertDictEqual(
run.data.tags,
{"hello": "world", "trial_name": "trial1", "mlflow.runName": "trial1"},
)
self.assertEqual(logger._trial_runs[trial], run.info.run_id)
# Params should be logged.
self.assertDictEqual(run.data.params, trial_config)
# When same trial is started again, new run should not be created.
logger.on_trial_start(iteration=0, trials=[], trial=trial)
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
self.assertEqual(len(all_runs), 1)
# Check metrics are logged properly.
result = {
"metric1": 0.8,
"metric2": 1,
"metric3": None,
"training_iteration": 0,
}
logger.on_trial_result(0, [], trial, result)
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
# metric3 is not logged since it cannot be converted to float.
self.assertDictEqual(
run.data.metrics, {"metric1": 0.8, "metric2": 1.0, "training_iteration": 0}
)
# Check that artifact is logged on termination.
logger.on_trial_complete(0, [], trial)
self.assertTrue(logger.mlflow_util.artifact_saved)
self.assertDictEqual(
logger.mlflow_util.artifact_info,
{"dir": "artifact", "run_id": run.info.run_id},
)
# Check if params are logged at the end.
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
self.assertDictEqual(run.data.params, trial_config)
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
def testMlFlowLoggerLogging_logAtEnd(self):
clear_env_vars()
trial_config = {"par1": "a", "par2": "b"}
trial = MockTrial(trial_config, "trial1", 0, "artifact")
logger = MLflowLoggerCallback(
tracking_uri=self.tracking_uri,
registry_uri=self.registry_uri,
experiment_name="test_log_at_end",
tags={"hello": "world"},
log_params_on_trial_end=True,
)
logger.setup()
exp_id = logger.mlflow_util.experiment_id
logger.on_trial_start(iteration=0, trials=[], trial=trial)
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=[exp_id])
self.assertEqual(len(all_runs), 1)
# all_runs is a pandas dataframe.
all_runs = all_runs.to_dict(orient="records")
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
# Params should NOT be logged at start.
self.assertDictEqual(run.data.params, {})
# Check that params are logged at the end.
logger.on_trial_complete(0, [], trial)
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
self.assertDictEqual(run.data.params, trial_config)
def testMlFlowSetupExplicit(self):
clear_env_vars()
trial_config = {"par1": 4, "par2": 9.0}
# No MLflow config passed in.
with self.assertRaises(ValueError):
setup_mlflow(trial_config)
# Invalid experiment-id
with self.assertRaises(ValueError):
setup_mlflow(trial_config, experiment_id="500")
# Set to experiment that does not already exist.
with self.assertRaises(ValueError):
setup_mlflow(
trial_config,
experiment_id="500",
experiment_name="new_experiment",
tracking_uri=self.tracking_uri,
)
mlflow = setup_mlflow(
trial_config,
experiment_id="500",
experiment_name="existing_experiment",
tracking_uri=self.tracking_uri,
)
mlflow.end_run()
@patch("ray.train.get_context")
def testMlFlowSetupRankNonRankZero(self, mock_get_context):
"""Assert that non-rank-0 workers get a noop module"""
mock_context = MagicMock()
mock_context.get_world_rank.return_value = 1
mock_get_context.return_value = mock_context
mlflow = setup_mlflow({})
assert isinstance(mlflow, _NoopModule)
mlflow.log_metrics()
mlflow.sklearn.save_model(None, "model_directory")
class MLflowUtilTest(unittest.TestCase):
def setUp(self):
self.dirpath = tempfile.mkdtemp()
import mlflow
mlflow.set_tracking_uri("sqlite:///" + self.dirpath + "/mlflow.sqlite")
mlflow.create_experiment(name="existing_experiment")
self.mlflow_util = _MLflowLoggerUtil()
self.tracking_uri = mlflow.get_tracking_uri()
def tearDown(self):
shutil.rmtree(self.dirpath)
def test_experiment_id(self):
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri, experiment_id="0")
assert self.mlflow_util.experiment_id == "0"
def test_experiment_id_env_var(self):
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
assert self.mlflow_util.experiment_id == "0"
del os.environ["MLFLOW_EXPERIMENT_ID"]
def test_experiment_name(self):
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name="existing_experiment"
)
assert self.mlflow_util.experiment_id == "1"
def test_run_started_with_correct_experiment(self):
experiment_name = "my_experiment_name"
# Make sure run is started under the correct experiment.
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name=experiment_name
)
run = self.mlflow_util.start_run(set_active=True)
assert (
run.info.experiment_id
== self.mlflow_util._mlflow.get_experiment_by_name(
experiment_name
).experiment_id
)
self.mlflow_util.end_run()
def test_experiment_name_env_var(self):
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
assert self.mlflow_util.experiment_id == "1"
del os.environ["MLFLOW_EXPERIMENT_NAME"]
def test_id_precedence(self):
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
)
assert self.mlflow_util.experiment_id == "0"
del os.environ["MLFLOW_EXPERIMENT_ID"]
def test_new_experiment(self):
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
)
assert self.mlflow_util.experiment_id == "2"
def test_setup_fail(self):
with self.assertRaises(ValueError):
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri,
experiment_name="new_experiment2",
create_experiment_if_not_exists=False,
)
def test_log_params(self):
params = {"a": "a", "x": {"y": "z"}}
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
)
run = self.mlflow_util.start_run()
run_id = run.info.run_id
self.mlflow_util.log_params(params_to_log=params, run_id=run_id)
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
assert run.data.params == flatten_dict(params)
params2 = {"b": "b"}
self.mlflow_util.start_run(set_active=True)
self.mlflow_util.log_params(params_to_log=params2, run_id=run_id)
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
assert run.data.params == flatten_dict(
{
**params,
**params2,
}
)
self.mlflow_util.end_run()
def test_log_metrics(self):
metrics = {"a": 1.0, "x": {"y": 2.0}}
self.mlflow_util.setup_mlflow(
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
)
run = self.mlflow_util.start_run()
run_id = run.info.run_id
self.mlflow_util.log_metrics(metrics_to_log=metrics, run_id=run_id, step=0)
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
assert run.data.metrics == flatten_dict(metrics)
metrics2 = {"b": 1.0}
self.mlflow_util.start_run(set_active=True)
self.mlflow_util.log_metrics(metrics_to_log=metrics2, run_id=run_id, step=0)
assert self.mlflow_util._mlflow.get_run(
run_id=run_id
).data.metrics == flatten_dict(
{
**metrics,
**metrics2,
}
)
self.mlflow_util.end_run()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,591 @@
"""Tests for wandb integration.
Note: These tests use a set of mocked APIs:
- _MockWandbAPI: Mocks wandb API calls (ex: wandb.init).
- _MockWandbLoggingActor: The same as the regular _WandbLoggingActor,
except using the mocked wandb API
- WandbTestExperimentLogger: Thin subclass of `WandbLoggerCallback` to use for testing.
Provides a helper `trial_logging_actors` property that can be used to
access attributes of the remote actors for assertions.
- Use the `get_mock_wandb_logger` helper method to create a logger with
a custom mock wandb API class. (Ex: If you want to override some wandb API methods.)
Template for testing with these mocks:
wandb_logger_kwargs = {}
logger = get_mock_wandb_logger(mock_api_cls=_MockWandbAPI, **wandb_logger_kwargs)
logger.setup()
# From now on, the API key is in the env variable.
# Start the remote logging actor
logger.on_trial_start(0, [], trial)
# Log some results
result = {}
logger.on_trial_result(0, [], trial, result)
# Send a STOP signal to the logging actor
logger.on_trial_complete(0, [], trial)
# This will wait for the logging actor to finish + cleanup
logger.on_experiment_end(trials=[trial])
# Now, we can access properties of the logging actors
# (must happen after `on_trial_end` and `on_experiment_end`)
logger_state = logger.trial_logging_actor_states[trial]
# logger_state.logs, logger_state.config, logger_state.kwargs, ...
"""
import gc
import os
import tempfile
import time
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pytest
import ray
from ray.air.integrations.wandb import (
WANDB_ENV_VAR,
WANDB_GROUP_ENV_VAR,
WANDB_POPULATE_RUN_LOCATION_HOOK,
WANDB_PROJECT_ENV_VAR,
WANDB_SETUP_API_KEY_HOOK,
RunDisabled,
WandbLoggerCallback,
_QueueItem,
_WandbLoggingActor,
setup_wandb,
)
from ray.air.tests.mocked_wandb_integration import (
Trial,
WandbTestExperimentLogger,
_MockWandbAPI,
_MockWandbLoggingActor,
get_mock_wandb_logger,
)
from ray.exceptions import RayActorError
from ray.tune.execution.placement_groups import PlacementGroupFactory
@pytest.fixture(autouse=True, scope="module")
def ray_start_2_cpus():
address_info = ray.init(num_cpus=2)
yield address_info
ray.shutdown()
@pytest.fixture
def trial():
trial_config = {"par1": 4, "par2": 9.12345678}
trial = Trial(
trial_config,
0,
"trial_0",
"trainable",
PlacementGroupFactory([{"CPU": 1}]),
"/tmp",
)
yield trial
@pytest.fixture(autouse=True)
def wandb_env():
"""Clean up W&B env var before and after each test.
Even if we use monkeypatch in the test, this is useful to remove environment
variables that are set on the laptop when running tests locally.
"""
if WANDB_ENV_VAR in os.environ:
del os.environ[WANDB_ENV_VAR]
yield
if WANDB_ENV_VAR in os.environ:
del os.environ[WANDB_ENV_VAR]
def fake_wandb_populate_run_location_hook():
"""Fake user-provided hook to populate W&B environment variables."""
os.environ[WANDB_PROJECT_ENV_VAR] = "test_project"
os.environ[WANDB_GROUP_ENV_VAR] = "test_group"
FAKE_WANDB_POPULATE_RUN_LOCATION_HOOK_IMPORT_PATH = (
"ray.air.tests.test_integration_wandb.fake_wandb_populate_run_location_hook"
)
class TestWandbLogger:
def test_wandb_logger_project_group(self, monkeypatch):
monkeypatch.setenv(WANDB_PROJECT_ENV_VAR, "test_project_from_env_var")
monkeypatch.setenv(WANDB_GROUP_ENV_VAR, "test_group_from_env_var")
# Read project and group name from environment variable
logger = WandbTestExperimentLogger(api_key="1234")
logger.setup()
assert logger.project == "test_project_from_env_var"
assert logger.group == "test_group_from_env_var"
def test_wandb_logger_api_key_config(self, monkeypatch):
# No API key
with pytest.raises(ValueError):
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
# Fetch API key from argument even if external hook and WANDB_ENV_VAR set
monkeypatch.setenv(
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
)
monkeypatch.setenv(
WANDB_ENV_VAR,
"abcde",
)
# API Key in config
logger = WandbTestExperimentLogger(project="test_project", api_key="1234")
logger.setup()
assert os.environ[WANDB_ENV_VAR] == "1234"
def test_wandb_logger_api_key_file(self, monkeypatch):
# Fetch API key from file even if external hook and WANDB_ENV_VAR set
monkeypatch.setenv(
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
)
monkeypatch.setenv(
WANDB_ENV_VAR,
"abcde",
)
# API Key file
with tempfile.NamedTemporaryFile("wt") as fp:
fp.write("5678")
fp.flush()
logger = WandbTestExperimentLogger(
project="test_project", api_key_file=fp.name
)
logger.setup()
assert os.environ[WANDB_ENV_VAR] == "5678"
def test_wandb_logger_api_key_env_var(self, monkeypatch):
# API Key from env var takes precedence over external hook and
# logged in W&B API key
monkeypatch.setenv(
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
)
monkeypatch.setenv(
WANDB_ENV_VAR,
"1234",
)
mock_wandb = Mock(api=Mock(api_key="efgh"))
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
assert os.environ[WANDB_ENV_VAR] == "1234"
mock_wandb.ensure_configured.assert_not_called()
def test_wandb_logger_api_key_external_hook(self, monkeypatch):
# API Key from external hook if API key not provided through
# argument or WANDB_ENV_VAR and user not already logged in to W&B
monkeypatch.setenv(
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
)
mock_wandb = Mock(api=Mock(api_key=None))
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
assert os.environ[WANDB_ENV_VAR] == "abcd"
mock_wandb.ensure_configured.assert_called_once()
mock_wandb = Mock(ensure_configured=Mock(side_effect=AttributeError()))
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
assert os.environ[WANDB_ENV_VAR] == "abcd"
def test_wandb_logger_api_key_from_wandb_login(self, monkeypatch):
# No API key should get set if user is already logged in to W&B
# and they didn't pass API key through argument or env var.
# External hook should not be called because user already logged
# in takes precedence.
monkeypatch.setenv(
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
)
mock_wandb = Mock()
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
assert os.environ.get(WANDB_ENV_VAR) is None
mock_wandb.ensure_configured.assert_called_once()
def test_wandb_logger_run_location_external_hook(self, monkeypatch):
with patch.dict(os.environ):
# No project
with pytest.raises(ValueError):
logger = WandbTestExperimentLogger(api_key="1234")
logger.setup()
# Project and group env vars from external hook
monkeypatch.setenv(
WANDB_POPULATE_RUN_LOCATION_HOOK,
FAKE_WANDB_POPULATE_RUN_LOCATION_HOOK_IMPORT_PATH,
)
logger = WandbTestExperimentLogger(api_key="1234")
logger.setup()
assert os.environ[WANDB_PROJECT_ENV_VAR] == "test_project"
assert os.environ[WANDB_GROUP_ENV_VAR] == "test_group"
def test_wandb_logger_start(self, monkeypatch, trial):
monkeypatch.setenv(WANDB_ENV_VAR, "9012")
# API Key in env
logger = WandbTestExperimentLogger(project="test_project")
logger.setup()
# From now on, the API key is in the env variable.
logger.log_trial_start(trial)
logger.log_trial_end(trial)
logger.on_experiment_end(trials=[trial])
logger_state = logger.trial_logging_actor_states[trial]
assert logger_state.kwargs["project"] == "test_project"
assert logger_state.kwargs["id"] == trial.trial_id
assert logger_state.kwargs["name"] == trial.trial_name
assert logger_state.kwargs["group"] == trial.experiment_dir_name
assert "config" in logger_state.exclude
del logger
# log config.
logger = WandbTestExperimentLogger(project="test_project", log_config=True)
logger.log_trial_start(trial)
logger.log_trial_end(trial)
logger.on_experiment_end(trials=[trial])
logger_state = logger.trial_logging_actor_states[trial]
assert "config" not in logger_state.exclude
assert "metric" not in logger_state.exclude
del logger
# Exclude metric.
logger = WandbTestExperimentLogger(project="test_project", excludes=["metric"])
logger.log_trial_start(trial)
logger.log_trial_end(trial)
logger.on_experiment_end(trials=[trial])
logger_state = logger.trial_logging_actor_states[trial]
assert "config" in logger_state.exclude
assert "metric" in logger_state.exclude
del logger
def test_wandb_logger_reporting(self, trial):
logger = WandbTestExperimentLogger(
project="test_project", api_key="1234", excludes=["metric2"]
)
logger.on_trial_start(0, [], trial)
r1 = {
"metric1": 0.8,
"metric2": 1.4,
"metric3": np.asarray(32.0),
"metric4": np.float32(32.0),
"const": "text",
"config": trial.config,
}
logger.on_trial_result(0, [], trial, r1)
logger.on_trial_complete(0, [], trial)
logger.on_experiment_end(trials=[trial])
logged = logger.trial_logging_actor_states[trial].logs[0]
assert "metric1" in logged
assert "metric2" not in logged
assert "metric3" in logged
assert "metric4" in logged
assert "const" not in logged
assert "config" not in logged
def test_wandb_logger_auto_config_keys(self, trial):
logger = WandbTestExperimentLogger(project="test_project", api_key="1234")
logger.on_trial_start(iteration=0, trials=[], trial=trial)
result = {key: 0 for key in WandbLoggerCallback.AUTO_CONFIG_KEYS}
logger.on_trial_result(0, [], trial, result)
logger.on_trial_complete(0, [], trial)
logger.on_experiment_end(trials=[trial])
config = logger.trial_logging_actor_states[trial].config
# The results in `AUTO_CONFIG_KEYS` should be saved as training configuration
# instead of output metrics.
assert set(WandbLoggerCallback.AUTO_CONFIG_KEYS) < set(config)
def test_wandb_logger_exclude_config(self):
trial = Trial(
config={"param1": 0, "param2": 0},
trial_id=0,
trial_name="trial_0",
experiment_dir_name="trainable",
placement_group_factory=PlacementGroupFactory([{"CPU": 1}]),
local_path=tempfile.gettempdir(),
)
logger = WandbTestExperimentLogger(
project="test_project",
api_key="1234",
excludes=(["param2"] + WandbLoggerCallback.AUTO_CONFIG_KEYS),
)
logger.on_trial_start(iteration=0, trials=[], trial=trial)
# We need to test that `excludes` also applies to `AUTO_CONFIG_KEYS`.
result = {key: 0 for key in WandbLoggerCallback.AUTO_CONFIG_KEYS}
logger.on_trial_result(0, [], trial, result)
logger.on_trial_complete(0, [], trial)
logger.on_experiment_end(trials=[trial])
config = logger.trial_logging_actor_states[trial].config
assert set(config) == {"param1"}
def test_set_serializability_result(self, trial):
"""Tests that objects that contain sets can be serialized by wandb."""
logger = WandbTestExperimentLogger(
project="test_project", api_key="1234", excludes=["metric2"]
)
logger.on_trial_start(0, [], trial)
# Testing for https://github.com/ray-project/ray/issues/28541
rllib_result = {
"env": "simple_spread",
"framework": "torch",
"num_gpus": 1,
"num_workers": 20,
"num_envs_per_env_runner": 1,
"compress_observations": True,
"lambda": 0.99,
"train_batch_size": 512,
"sgd_minibatch_size": 32,
"num_sgd_iter": 5,
"batch_mode": "truncate_episodes",
"entropy_coeff": 0.01,
"lr": 2e-05,
"multiagent": {
"policies": {"shared_policy"},
"policy_mapping_fn": lambda x: x,
},
}
logger.on_trial_result(0, [], trial, rllib_result)
logger.on_trial_complete(0, [], trial)
logger.on_experiment_end(trials=[trial])
logged = logger.trial_logging_actor_states[trial].logs[0]
assert logged != "serialization error"
def test_wandb_logging_actor_api_key(self, trial, monkeypatch):
"""Tests that the wandb API key get propagated as an environment variable to
the remote logging actors."""
def mock_run(actor_cls):
return os.environ.get(WANDB_ENV_VAR)
monkeypatch.setattr(_MockWandbLoggingActor, "run", mock_run)
logger = WandbLoggerCallback(
project="test_project", api_key="1234", excludes=["metric2"]
)
logger._logger_actor_cls = _MockWandbLoggingActor
logger.setup()
logger.log_trial_start(trial)
actor_env_var = ray.get(logger._trial_logging_futures[trial])
assert actor_env_var == "1234"
def test_wandb_finish(self, trial, tmp_path):
"""Test that logging actors are cleaned up upon experiment completion."""
marker = tmp_path / "hang_marker"
marker.write_text("")
class HangingFinishMockWandbAPI(_MockWandbAPI):
def finish(self):
while marker.exists():
time.sleep(0.1)
logger = get_mock_wandb_logger(
mock_api_cls=HangingFinishMockWandbAPI,
upload_timeout=1.0,
)
logger.setup()
logger.on_trial_start(0, [], trial)
logger.on_trial_complete(0, [], trial)
# Signalling stop will not cleanup fully due to the hanging finish
assert logger._trial_logging_actors
marker.unlink()
# wandb.finish has ended -> experiment end hook should cleanup actors fully
logger.on_experiment_end(trials=[trial])
assert not logger._trial_logging_actors
def test_wandb_kill_hanging_actor(self, trial):
"""Test that logging actors are killed if exceeding the upload timeout
upon experiment completion."""
class HangingFinishMockWandbAPI(_MockWandbAPI):
def finish(self):
time.sleep(5)
logger = get_mock_wandb_logger(
mock_api_cls=HangingFinishMockWandbAPI,
upload_timeout=0.1,
)
logger.setup()
logger.on_trial_start(0, [], trial)
logger.on_trial_complete(0, [], trial)
# Signalling stop will not cleanup fully due to the hanging finish
assert logger._trial_logging_actors
actor = logger._trial_logging_actors[trial]
# Experiment end hook should kill actors since upload_timeout < 5
logger.on_experiment_end(trials=[trial])
assert not logger._trial_logging_actors
gc.collect()
with pytest.raises(RayActorError):
ray.get(actor.get_state.remote())
def test_wandb_destructor(self, trial):
"""Test that the WandbLoggerCallback destructor forcefully cleans up
logging actors."""
class SlowFinishMockWandbAPI(_MockWandbAPI):
def finish(self):
time.sleep(5)
logger = get_mock_wandb_logger(
mock_api_cls=SlowFinishMockWandbAPI,
upload_timeout=1.0,
)
logger.setup()
# Triggers logging actor run loop
logger.on_trial_start(0, [], trial)
actor = logger._trial_logging_actors[trial]
del logger
gc.collect()
with pytest.raises(RayActorError):
ray.get(actor.get_state.remote())
def test_wandb_logging_actor_fault_tolerance(self, trial):
"""Tests that failing wandb logging actors are restarted"""
with tempfile.TemporaryDirectory() as tempdir:
fail_marker = Path(tempdir) / "fail_marker"
class _FailingWandbLoggingActor(_MockWandbLoggingActor):
def _handle_result(self, result):
if (
result.get("training_iteration") == 3
and not fail_marker.exists()
):
fail_marker.write_text("Ok")
raise SystemExit
return super()._handle_result(result)
logger = WandbLoggerCallback(
project="test_project", api_key="1234", excludes=["metric2"]
)
logger._logger_actor_cls = _FailingWandbLoggingActor
logger.setup()
logger.log_trial_start(trial)
actor = logger._trial_logging_actors[trial]
queue = logger._trial_queues[trial]
logger.log_trial_result(1, trial, result={"training_iteration": 1})
logger.log_trial_result(2, trial, result={"training_iteration": 2})
logger.log_trial_result(3, trial, result={"training_iteration": 3})
logger.log_trial_result(4, trial, result={"training_iteration": 4})
logger.log_trial_result(5, trial, result={"training_iteration": 5})
queue.put((_QueueItem.END, None))
# Wait for the actor's run method to complete
ray.get(logger._trial_logging_futures[trial])
state = ray.get(actor.get_state.remote())
assert [metrics["training_iteration"] for metrics in state.logs] == [4, 5]
def test_wandb_restart(self, trial):
"""Test that the WandbLoggerCallback reuses actors for trial restarts."""
logger = WandbLoggerCallback(project="test_project", api_key="1234")
logger._logger_actor_cls = _MockWandbLoggingActor
logger.setup()
assert len(logger._trial_logging_futures) == 0
assert len(logger._logging_future_to_trial) == 0
logger.log_trial_start(trial)
assert len(logger._trial_logging_futures) == 1
assert len(logger._logging_future_to_trial) == 1
logger.log_trial_start(trial)
assert len(logger._trial_logging_futures) == 1
assert len(logger._logging_future_to_trial) == 1
def test_wandb_logging_process_run_info_hook(monkeypatch):
"""
Test WANDB_PROCESS_RUN_INFO_HOOK in _WandbLoggingActor is
correctly called by calling _WandbLoggingActor.run() mocking
out calls to wandb.
"""
mock_queue = Mock(get=Mock(return_value=(_QueueItem.END, None)))
monkeypatch.setenv(
"WANDB_PROCESS_RUN_INFO_HOOK", "mock_wandb_process_run_info_hook"
)
with patch.object(ray.air.integrations.wandb, "load_class") as mock_load_class:
logging_process = _WandbLoggingActor(
logdir="/tmp", queue=mock_queue, exclude=[], to_config=[]
)
logging_process._wandb = Mock()
logging_process.run()
logging_process._wandb.init.assert_called_once()
run = logging_process._wandb.init.return_value
mock_load_class.assert_called_once_with("mock_wandb_process_run_info_hook")
external_hook = mock_load_class.return_value
external_hook.assert_called_once_with(run)
logging_process._wandb.finish.assert_called_once()
def test_wandb_logger_rank_zero_only(trial, monkeypatch):
"""Test that logging is disabled for non-rank-0 workers when rank_zero_only is True."""
monkeypatch.setenv(
WANDB_ENV_VAR,
"abcde",
)
mock_session = Mock()
mock_session.experiment_name = "test_project"
mock_session.trial_name = "trial_0"
mock_session.trial_id = "trial_0"
# Test case 1: rank_zero_only=True, rank 0
mock_session.world_rank = 0
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
assert not isinstance(run, RunDisabled)
# Test case 2: rank_zero_only=True, non-rank-0
mock_session.world_rank = 1
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
assert isinstance(run, RunDisabled)
# Test case 3: rank_zero_only=False, any rank
mock_session.world_rank = 1
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
run = setup_wandb(project="test_project", rank_zero_only=False, _wandb=Mock())
assert not isinstance(run, RunDisabled)
# Test case 4: rank_zero_only=True, no session
with patch("ray.air.integrations.wandb.get_session", return_value=None):
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
assert not isinstance(run, RunDisabled)
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", __file__]))
+223
View File
@@ -0,0 +1,223 @@
import os
import sys
from typing import Dict, Tuple
from unittest.mock import patch
import numpy as np
import pytest
import ray
from ray import train
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:
import tensorflow as tf
from ray.air.integrations.keras import ReportCheckpointCallback
from ray.train.tensorflow import TensorflowTrainer
class TestReportCheckpointCallback:
@pytest.fixture(name="model")
def model_fixture(self):
model = tf.keras.Sequential(
[tf.keras.layers.InputLayer(input_shape=(1,)), tf.keras.layers.Dense(1)]
)
model.compile(
optimizer="sgd",
loss="mean_squared_error",
metrics=["accuracy"],
)
return model
@patch("ray.train.report")
@pytest.mark.parametrize(
"metrics, expected_metrics_keys",
[
(None, {"loss", "accuracy", "val_loss", "val_accuracy"}),
("loss", {"loss"}),
(["loss", "accuracy"], {"loss", "accuracy"}),
({"spam": "loss"}, {"spam"}),
],
)
def test_reported_metrics_contain_expected_keys(
self, mock_report, metrics, expected_metrics_keys, model
):
# Reported metrics contain different keys depending on the value passed to the
# `metrics` parameter. This test varies the value of `metrics` and asserts that
# the reported keys are correct.
model.fit(
x=np.zeros((1, 1)),
y=np.zeros((1, 1)),
validation_data=(np.zeros((1, 1)), np.zeros((1, 1))),
callbacks=[ReportCheckpointCallback(metrics=metrics)],
)
for (metrics,), _ in ray.train.report.call_args_list:
assert metrics.keys() == expected_metrics_keys
@patch("ray.train.report")
def test_report_with_default_arguments(self, mock_report, model):
# This tests `ReportCheckpointCallback` with default arguments. The test
# simulates the end of an epoch, and asserts that a metric and checkpoint are
# reported.
callback = ReportCheckpointCallback()
callback.set_model(model)
callback.on_epoch_end(0, {"loss": 0})
assert len(ray.train.report.call_args_list) == 1
metrics, checkpoint = self.parse_call(ray.train.report.call_args_list[0])
assert metrics == {"loss": 0}
assert checkpoint is not None
@patch("ray.train.report")
def test_checkpoint_on_list(self, mock_report, model):
# This tests `ReportCheckpointCallback` when `checkpoint_on` is a `list`. The
# test simulates each event in `checkpoint_on`, and asserts that a checkpoint
# is reported for each event.
callback = ReportCheckpointCallback(
checkpoint_on=["epoch_end", "train_batch_end"]
)
callback.model = model
callback.on_train_batch_end(0, {"loss": 0})
callback.on_epoch_end(0, {"loss": 0})
assert len(ray.train.report.call_args_list) == 2
_, first_checkpoint = self.parse_call(ray.train.report.call_args_list[0])
assert first_checkpoint is not None
_, second_checkpoint = self.parse_call(ray.train.report.call_args_list[0])
assert second_checkpoint is not None
@patch("ray.train.report")
def test_report_metrics_on_list(self, mock_report, model):
# This tests `ReportCheckpointCallback` when `report_metrics_on` is a `list`.
# The test simulates each event in `report_metrics_on`, and asserts that metrics
# are reported for each event.
callback = ReportCheckpointCallback(
report_metrics_on=["epoch_end", "train_batch_end"]
)
callback.model = model
callback.on_train_batch_end(0, {"loss": 0})
callback.on_epoch_end(0, {"loss": 1})
assert len(ray.train.report.call_args_list) == 2
first_metric, _ = self.parse_call(ray.train.report.call_args_list[0])
assert first_metric == {"loss": 0}
second_metric, _ = self.parse_call(ray.train.report.call_args_list[1])
assert second_metric == {"loss": 1}
@patch("ray.train.report")
def test_report_and_checkpoint_on_different_events(self, mock_report, model):
# This tests `ReportCheckpointCallback` when `report_metrics_on` and
# `checkpoint_on` are different. The test asserts that:
# 1. Checkpoints are reported on `checkpoint_on`
# 2. Metrics are reported on `report_metrics_on`
# 3. Metrics are reported with checkpoints
callback = ReportCheckpointCallback(
report_metrics_on="train_batch_end", checkpoint_on="epoch_end"
)
callback.model = model
callback.on_train_batch_end(0, {"loss": 0})
callback.on_epoch_end(0, {"loss": 1})
assert len(ray.train.report.call_args_list) == 2
first_metric, first_checkpoint = self.parse_call(
ray.train.report.call_args_list[0]
)
assert first_metric == {"loss": 0}
assert first_checkpoint is None
second_metric, second_checkpoint = self.parse_call(
ray.train.report.call_args_list[1]
)
# We should always include metrics, even if it isn't during one of the events
# specified in `report_metrics_on`.
assert second_metric == {"loss": 1}
assert second_checkpoint is not None
@patch("ray.train.report")
def test_report_delete_tempdir(self, mock_report, model):
# This tests `ReportCheckpointCallback`. The test simulates the end of an epoch,
# and asserts that the temporary checkpoint directory is deleted afterwards.
callback = ReportCheckpointCallback()
callback.model = model
callback.on_epoch_end(0, {"loss": 0})
assert len(ray.train.report.call_args_list) == 1
metrics, checkpoint = self.parse_call(ray.train.report.call_args_list[0])
assert metrics == {"loss": 0}
assert checkpoint is not None
assert checkpoint.path is not None
assert not os.path.exists(checkpoint.path)
def parse_call(self, call) -> Tuple[Dict, train.Checkpoint]:
(metrics,), kwargs = call
checkpoint = kwargs["checkpoint"]
return metrics, checkpoint
def get_dataset(a=5, b=10, size=1000):
items = [i / size for i in range(size)]
dataset = ray.data.from_items([{"x": x, "y": a * x + b} for x in items])
return dataset
def build_model() -> tf.keras.Model:
model = tf.keras.Sequential(
[
tf.keras.layers.InputLayer(input_shape=()),
# Add feature dimension, expanding (batch_size,) to (batch_size, 1).
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10),
tf.keras.layers.Dense(1),
]
)
return model
def train_func(config: dict):
strategy = tf.distribute.MultiWorkerMirroredStrategy()
with strategy.scope():
# Model building/compiling need to be within `strategy.scope()`.
multi_worker_model = build_model()
multi_worker_model.compile(
optimizer=tf.keras.optimizers.SGD(learning_rate=config.get("lr", 1e-3)),
loss=tf.keras.losses.mean_squared_error,
metrics=[tf.keras.metrics.mean_squared_error],
)
dataset = train.get_dataset_shard("train")
for _ in range(config.get("epoch", 3)):
tf_dataset = dataset.to_tf("x", "y", batch_size=32)
multi_worker_model.fit(tf_dataset, callbacks=[ReportCheckpointCallback()])
def test_keras_callback_e2e():
epochs = 3
config = {
"epochs": epochs,
}
trainer = TensorflowTrainer(
train_loop_per_worker=train_func,
train_loop_config=config,
scaling_config=ScalingConfig(num_workers=2),
datasets={TRAIN_DATASET_KEY: get_dataset()},
)
trainer.fit()
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", "-x", __file__]))
@@ -0,0 +1,394 @@
import random
from typing import Optional
from unittest.mock import MagicMock
import pytest
import ray
from ray import train
from ray.data import DataIterator
from ray.data._internal.execution.interfaces.execution_options import (
ExecutionOptions,
ExecutionResources,
)
from ray.tests.conftest import * # noqa
from ray.train import DataConfig, ScalingConfig
from ray.train.data_parallel_trainer import DataParallelTrainer
@pytest.fixture
def ray_start_4_cpus():
address_info = ray.init(num_cpus=4)
yield address_info
ray.shutdown()
class TestBasic(DataParallelTrainer):
def __init__(
self, num_workers: int, expect_ds: bool, expect_sizes: Optional[dict], **kwargs
):
def train_loop_per_worker():
data_shard = train.get_dataset_shard("train")
assert isinstance(data_shard, DataIterator), data_shard
for k, v in expect_sizes.items():
shard = train.get_dataset_shard(k)
if v == -1:
assert shard is None, shard
else:
count = 0
for batch in shard.iter_batches():
for arr in batch.values():
count += arr.size
assert count == v, shard
kwargs.pop("scaling_config", None)
super().__init__(
train_loop_per_worker=train_loop_per_worker,
scaling_config=ScalingConfig(num_workers=num_workers),
**kwargs,
)
def test_basic(ray_start_4_cpus):
ds = ray.data.range(10)
# Single worker basic case.
test = TestBasic(
1,
True,
{"train": 10, "test": 10},
datasets={"train": ds, "test": ds},
)
test.fit()
# Single worker, no test ds.
test = TestBasic(1, True, {"train": 10, "test": -1}, datasets={"train": ds})
test.fit()
# Two workers, train and test split.
test = TestBasic(
2, True, {"train": 5, "test": 5}, datasets={"train": ds, "test": ds}
)
test.fit()
# Two workers, both split.
test = TestBasic(
2,
True,
{"train": 5, "test": 5},
dataset_config=DataConfig(datasets_to_split=["train", "test"]),
datasets={"train": ds, "test": ds},
)
# Test get config.
assert isinstance(test.get_dataset_config(), DataConfig)
test.fit()
def test_split(ray_start_4_cpus):
ds = ray.data.range(10)
# Split all by default
test = TestBasic(
2,
True,
{"train": 5, "test": 5, "val": 5},
datasets={"train": ds, "test": ds, "val": ds},
)
test.fit()
# Test flag "all"
test = TestBasic(
2,
True,
{"train": 5, "test": 5},
datasets={"train": ds, "test": ds},
dataset_config=DataConfig(datasets_to_split="all"),
)
# Test split train only.
test = TestBasic(
2,
True,
{"train": 5, "test": 10},
datasets={"train": ds, "test": ds},
dataset_config=DataConfig(datasets_to_split=["train"]),
)
test.fit()
# Test invalid arguments
for datasets_to_split in ["train", ("train"), {}]:
with pytest.raises(TypeError, match="`datasets_to_split` should be.*"):
test = TestBasic(
2,
True,
{"train": 5, "test": 10},
datasets={"train": ds, "test": ds},
dataset_config=DataConfig(datasets_to_split=datasets_to_split),
)
# Test empty `datasets_to_split` list
test = TestBasic(
2,
True,
{"train": 10, "test": 10},
datasets={"train": ds, "test": ds},
dataset_config=DataConfig(datasets_to_split=[]),
)
test.fit()
def test_configure_execution_options_carryover_context(ray_start_4_cpus):
"""Tests that execution options in DataContext are carried over to DatConfig
automatically."""
ctx = ray.data.DataContext.get_current()
ctx.execution_options.preserve_order = True
ctx.execution_options.verbose_progress = True
data_config = DataConfig()
ingest_options = data_config.default_ingest_options()
assert ingest_options.preserve_order is True
assert ingest_options.verbose_progress is True
@pytest.mark.parametrize("enable_locality", [True, False])
def test_configure_locality(enable_locality):
data_config = DataConfig(enable_shard_locality=enable_locality)
mock_ds = MagicMock()
mock_ds.streaming_split = MagicMock()
mock_ds.copy = MagicMock(return_value=mock_ds)
world_size = 2
worker_handles = [MagicMock() for _ in range(world_size)]
worker_node_ids = ["node" + str(i) for i in range(world_size)]
data_config.configure(
datasets={"train": mock_ds},
world_size=world_size,
worker_handles=worker_handles,
worker_node_ids=worker_node_ids,
)
mock_ds.streaming_split.assert_called_once()
mock_ds.streaming_split.assert_called_with(
world_size,
equal=True,
locality_hints=worker_node_ids if enable_locality else None,
)
class CustomConfig(DataConfig):
def __init__(self):
pass
def configure(self, *args, **kwargs):
ds = ray.data.range(10)
return [
{"train": ds.iterator()},
{"train": ds.iterator()},
]
def test_custom_config_subclass(ray_start_4_cpus):
test = TestBasic(
1,
True,
{"train": 10},
dataset_config=CustomConfig(),
)
test.fit()
class TestRandom(DataParallelTrainer):
def __init__(self, num_workers: int, expect_random: bool, **kwargs):
def train_loop_per_worker():
data_shard = train.get_dataset_shard("train")
assert isinstance(data_shard, DataIterator), data_shard
epoch1 = list(data_shard.iter_rows())
epoch2 = list(data_shard.iter_rows())
print("Epochs", epoch1, "\n", epoch2)
if expect_random:
assert epoch1 != epoch2
else:
assert epoch1 == epoch2
kwargs.pop("scaling_config", None)
super().__init__(
train_loop_per_worker=train_loop_per_worker,
scaling_config=ScalingConfig(num_workers=num_workers),
**kwargs,
)
def test_per_epoch_preprocessing(ray_start_4_cpus):
ds = ray.data.range(100, override_num_blocks=100).randomize_block_order()
test = TestRandom(2, True, datasets={"train": ds})
test.fit()
ds = ray.data.range(100, override_num_blocks=100).random_shuffle()
test = TestRandom(2, True, datasets={"train": ds})
test.fit()
ds = ray.data.range(100, override_num_blocks=100).map(
lambda x: {"id": x["id"] * random.random()}
)
test = TestRandom(2, True, datasets={"train": ds})
test.fit()
def test_materialized_preprocessing(ray_start_4_cpus):
# TODO(ekl) we should test all these configs with splitting enabled, but this
# requires implementing deterministic streaming split.
ds = ray.data.range(100, override_num_blocks=100).randomize_block_order()
ds = ds.materialize()
test = TestRandom(
2,
False,
datasets={"train": ds},
dataset_config=DataConfig(datasets_to_split=[]),
)
test.fit()
ds = ray.data.range(100, override_num_blocks=100).random_shuffle()
ds = ds.materialize()
test = TestRandom(
2,
False,
datasets={"train": ds},
dataset_config=DataConfig(datasets_to_split=[]),
)
test.fit()
ds = ray.data.range(100, override_num_blocks=100).map(
lambda x: {"id": x["id"] * random.random()}
)
ds = ds.materialize()
test = TestRandom(
2,
False,
datasets={"train": ds},
dataset_config=DataConfig(datasets_to_split=[]),
)
test.fit()
def _run_data_config_resource_test(data_config):
cluster_cpus, cluster_gpus = 20, 10
num_workers = 2
# Resources used by training workers.
cpus_per_worker, gpus_per_worker = 2, 1
original_execution_options = data_config._get_execution_options("train")
ray.init(num_cpus=cluster_cpus, num_gpus=cluster_gpus)
class MyTrainer(DataParallelTrainer):
def __init__(self, **kwargs):
def train_loop_fn():
train_ds = train.get_dataset_shard("train")
new_execution_options = train_ds.get_context().execution_options
if original_execution_options.is_resource_limits_default():
# If the original resource limits are default, the new resource
# limits should be the default as well.
assert new_execution_options.is_resource_limits_default()
exclude_resources = new_execution_options.exclude_resources
assert (
exclude_resources.cpu
== original_execution_options.exclude_resources.cpu
+ cpus_per_worker * num_workers
+ 1 # trainer coordinator
)
assert (
exclude_resources.gpu
== original_execution_options.exclude_resources.gpu
+ gpus_per_worker * num_workers
)
else:
# If the original resource limits are not default, the new resource
# limits should be the same as the original ones.
# And the new exclude_resources should be zero.
assert (
new_execution_options.resource_limits
== original_execution_options.resource_limits
)
assert (
new_execution_options.exclude_resources
== ExecutionResources.zero()
)
kwargs.pop("scaling_config", None)
super().__init__(
train_loop_per_worker=train_loop_fn,
scaling_config=ScalingConfig(
num_workers=num_workers,
use_gpu=True,
resources_per_worker={
"CPU": cpus_per_worker,
"GPU": gpus_per_worker,
},
),
datasets={"train": ray.data.range(10)},
dataset_config=data_config,
**kwargs,
)
trainer = MyTrainer()
trainer.fit()
def test_data_config_default_resource_limits(shutdown_only):
"""Test that DataConfig preserves user-configured exclude_resources."""
execution_options = ExecutionOptions()
execution_options.exclude_resources = execution_options.exclude_resources.copy(
cpu=2, gpu=1
)
data_config = DataConfig(execution_options=execution_options)
_run_data_config_resource_test(data_config)
def test_data_config_manual_resource_limits(shutdown_only):
"""Test manually setting resource limits in DataConfig."""
execution_options = ExecutionOptions()
execution_options.resource_limits = execution_options.resource_limits.copy(
cpu=10, gpu=5
)
data_config = DataConfig(execution_options=execution_options)
_run_data_config_resource_test(data_config)
def test_v1_train_with_v2_data_autoscaler_sets_exclude_resources(
shutdown_only, monkeypatch
):
"""Regression test for the Train V1 + V2 cluster autoscaler combination."""
monkeypatch.setenv("RAY_DATA_CLUSTER_AUTOSCALER", "V2")
ray.init(num_cpus=10, num_gpus=2)
num_train_cpus, num_train_gpus = 4.0, 2.0
data_config = DataConfig()
data_config.set_train_total_resources(
num_train_cpus=num_train_cpus, num_train_gpus=num_train_gpus
)
iterators = data_config.configure(
datasets={"train": ray.data.range(10)},
world_size=2,
worker_handles=None,
worker_node_ids=None,
)
exclude_resources = (
iterators[0]["train"].get_context().execution_options.exclude_resources
)
assert exclude_resources.cpu == num_train_cpus
assert exclude_resources.gpu == num_train_gpus
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", "-x", __file__]))
@@ -0,0 +1,50 @@
"""Test remote_storage in a ci environment with real hdfs setup."""
import os
import pytest
from ray.train.v2._internal.execution.storage import (
_list_at_fs_path,
_upload_to_fs_path,
get_fs_and_path,
)
@pytest.fixture
def setup_hdfs():
"""Set env vars required by pyarrow to talk to hdfs correctly.
Returns hostname and port needed for the hdfs uri."""
# the following file is written in `install-hdfs.sh`.
with open("/tmp/hdfs_env", "r") as f:
for line in f.readlines():
line = line.rstrip("\n")
tokens = line.split("=", maxsplit=1)
os.environ[tokens[0]] = tokens[1]
import sys
sys.path.insert(0, os.path.join(os.environ["HADOOP_HOME"], "bin"))
hostname = os.getenv("CONTAINER_ID")
port = os.getenv("HDFS_PORT")
yield hostname, port
def test_hdfs(tmp_path, setup_hdfs):
pytest.skip("TODO: Fix this test")
hostname, port = setup_hdfs
hdfs_uri = f"hdfs://{hostname}:{port}/test/"
fs, path = get_fs_and_path(hdfs_uri)
dummy_file = tmp_path.joinpath("dummy.txt")
dummy_file.write_text("dummy")
_upload_to_fs_path(dummy_file, fs, path)
assert _list_at_fs_path(fs, path) == ["dummy.txt"]
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
+102
View File
@@ -0,0 +1,102 @@
import pytest
from tblib import pickling_support
import ray
from ray import cloudpickle
from ray.air._internal.util import StartTraceback, exception_cause, skip_exceptions
from ray.tune import Tuner
@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()
def _failing_recursive(levels: int = 0, start_traceback: int = -1):
if levels > 0:
if start_traceback == 0:
try:
_failing_recursive(
levels=levels - 1, start_traceback=start_traceback - 1
)
except Exception as e:
raise StartTraceback from e
else:
_failing_recursive(levels=levels - 1, start_traceback=start_traceback - 1)
else:
raise RuntimeError("Failing")
@pytest.mark.parametrize("levels", [4, 5, 6, 7, 8, 9, 10])
def test_short_traceback(levels):
start_traceback = 3
with pytest.raises(StartTraceback) as exc_info:
_failing_recursive(levels=levels, start_traceback=start_traceback)
exc = skip_exceptions(exc_info.value)
tb = exc.__traceback__
i = 0
while tb:
i += 1
tb = tb.tb_next
assert i == levels - start_traceback + 1
def test_recursion():
"""Test that the skipped exception does not point to the original exception."""
root_exception = None
with pytest.raises(StartTraceback) as exc_info:
try:
raise Exception("Root Exception")
except Exception as e:
root_exception = e
raise StartTraceback from root_exception
assert root_exception, "Root exception was not captured."
start_traceback = exc_info.value
skipped_exception = skip_exceptions(start_traceback)
assert (
root_exception != skipped_exception
), "Skipped exception points to the original exception."
def test_tblib():
"""Test that tblib does not cause a maximum recursion error."""
with pytest.raises(Exception) as exc_info:
try:
try:
raise Exception("Root Exception")
except Exception as root_exception:
raise StartTraceback from root_exception
except Exception as start_traceback:
raise skip_exceptions(start_traceback) from exception_cause(start_traceback)
pickling_support.install()
reraised_exception = exc_info.value
# This should not raise a RecursionError/PicklingError.
cloudpickle.dumps(reraised_exception)
def test_traceback_tuner(ray_start_2_cpus):
"""Ensure that the Tuner's stack trace is not too long."""
def failing(config):
raise RuntimeError("Error")
tuner = Tuner(failing)
results = tuner.fit()
assert len(str(results[0].error).split("\n")) <= 20
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))
+34
View File
@@ -0,0 +1,34 @@
"""Test AIR internal utilities (under ray.air._internal)."""
import os
import pytest
import ray
from ray.air._internal.filelock import RAY_LOCKFILE_DIR, TempFileLock
def test_temp_file_lock(tmp_path, monkeypatch):
"""Test that the directory where temp file locks are saved can be configured
via the env variable that configures the global Ray temp dir."""
monkeypatch.setenv("RAY_TMPDIR", str(tmp_path))
assert str(tmp_path) in ray._common.utils.get_default_system_temp_dir()
with TempFileLock(path="abc.txt"):
assert RAY_LOCKFILE_DIR in os.listdir(tmp_path)
assert os.listdir(tmp_path / RAY_LOCKFILE_DIR)
def test_multiple_file_locks(tmp_path, monkeypatch):
"""Test that a new file lock is created for unique paths."""
monkeypatch.setenv("RAY_TMPDIR", str(tmp_path))
with TempFileLock(path="abc.txt"):
with TempFileLock(path="subdir/abc.txt"):
assert RAY_LOCKFILE_DIR in os.listdir(tmp_path)
# We should have 2 locks, one for abc.txt and one for subdir/abc.txt
assert len(os.listdir(tmp_path / RAY_LOCKFILE_DIR)) == 2
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))