Files
ray-project--ray/python/ray/tune/tests/test_tune_restore.py
T
2026-07-13 13:17:40 +08:00

679 lines
22 KiB
Python

# coding: utf-8
import multiprocessing
import os
import shutil
import signal
import subprocess
import tempfile
import threading
import time
import unittest
from collections import Counter
from typing import List
from unittest import mock
import numpy as np
import pytest
import ray
from ray import tune
from ray._common.test_utils import run_string_as_driver
from ray.exceptions import RayTaskError
from ray.train._internal.session import _TrainingResult
from ray.tune import Checkpoint, TuneError
from ray.tune.callback import Callback
from ray.tune.execution.tune_controller import TuneController
from ray.tune.experiment import Trial
from ray.tune.search import Searcher
from ray.tune.search.basic_variant import BasicVariantGenerator
from ray.tune.utils import validate_save_restore
from ray.tune.utils.mock_trainable import MyTrainableClass
# Defining the callbacks at the file level, so they can be pickled and spawned
# in a separate process.
class SteppingCallback(Callback):
def __init__(self, driver_semaphore, trainer_semaphore):
self.driver_semaphore = driver_semaphore
self.trainer_semaphore = trainer_semaphore
def on_step_end(self, iteration, trials, **info):
self.driver_semaphore.release() # Driver should continue
self.trainer_semaphore.acquire() # Wait until released
def _run(local_dir, driver_semaphore, trainer_semaphore):
def _train(config):
for i in range(7):
ray.tune.report(dict(val=i))
tune.run(
_train,
storage_path=local_dir,
name="interrupt",
callbacks=[SteppingCallback(driver_semaphore, trainer_semaphore)],
)
class TuneInterruptionTest(unittest.TestCase):
def testExperimentInterrupted(self):
local_dir = tempfile.mkdtemp()
# Unix platforms may default to "fork", which is problematic with
# multithreading and GRPC. The child process should always be spawned.
mp_ctx = multiprocessing.get_context("spawn")
driver_semaphore = mp_ctx.Semaphore()
trainer_semaphore = mp_ctx.Semaphore()
process = mp_ctx.Process(
target=_run,
args=(local_dir, driver_semaphore, trainer_semaphore),
name="tune_interrupt",
)
process.daemon = False
process.start()
exp_dir = os.path.join(local_dir, "interrupt")
# Skip first five steps
for i in range(5):
driver_semaphore.acquire() # Wait for callback
trainer_semaphore.release() # Continue training
driver_semaphore.acquire()
experiment_state_file = None
for file in os.listdir(exp_dir):
if file.startswith("experiment_state"):
experiment_state_file = os.path.join(exp_dir, file)
break
self.assertTrue(experiment_state_file)
last_mtime = os.path.getmtime(experiment_state_file)
# Now send kill signal
os.kill(process.pid, signal.SIGINT)
# Release trainer. It should handle the signal and try to
# checkpoint the experiment
trainer_semaphore.release()
time.sleep(2) # Wait for checkpoint
new_mtime = os.path.getmtime(experiment_state_file)
self.assertNotEqual(last_mtime, new_mtime)
shutil.rmtree(local_dir)
def testInterruptDisabledInWorkerThread(self):
# https://github.com/ray-project/ray/issues/22295
# This test will hang without the proper patch because tune.run will fail.
event = threading.Event()
def run_in_thread():
def _train(config):
for i in range(7):
ray.tune.report(dict(val=i))
tune.run(_train)
event.set()
thread = threading.Thread(target=run_in_thread)
thread.start()
event.wait()
thread.join()
ray.shutdown()
os.environ.pop("TUNE_DISABLE_SIGINT_HANDLER", None)
class TuneFailResumeGridTest(unittest.TestCase):
class FailureInjectorCallback(Callback):
"""Adds random failure injection to the TrialExecutor."""
def __init__(self, num_trials=20, delay_s=0.3):
self.num_trials = num_trials
self.delay_s = delay_s
self.fail_at = None
def on_step_end(self, trials, **kwargs):
if self.fail_at:
if time.monotonic() >= self.fail_at:
raise RuntimeError(f"Failing after {self.delay_s}")
return
if len(trials) >= self.num_trials:
print(
f"Reached {self.num_trials} trials. "
f"Scheduling failure in {self.delay_s} seconds."
)
self.fail_at = time.monotonic() + self.delay_s
class CheckStateCallback(Callback):
"""Checks state for the experiment initialization."""
def __init__(self, expected_trials=20):
self.expected_trials = expected_trials
self._checked = False
def on_step_begin(self, iteration, trials, **kwargs):
if not self._checked:
assert len(trials) == self.expected_trials
self._checked = True
class CheckTrialResourcesCallback(Callback):
"""Checks if pending trials are requesting the right amount of
resources.
The check happens exactly once after `check_after` number of calls
to on_step_begin(). Note, we deliberately delay the check to after
`check_after` number of steps. This is because when we start a
tuning job from fresh (rather than restored), trial list is still
empty - any check now would be trivial and thus wasted.
"""
def __init__(self, expected_cpu: int, check_after: int = 1):
self._expected_cpu = expected_cpu
self._checked = False
self._check_after = check_after
def on_step_begin(self, iteration: int, trials: List["Trial"], **info):
if not self._checked and iteration >= self._check_after:
for trial in trials:
if trial.status == Trial.PENDING:
assert (
trial.placement_group_factory.required_resources.get(
"CPU", 0
)
== self._expected_cpu
)
self._checked = True
def setUp(self):
self.logdir = tempfile.mkdtemp()
# These tests need driver syncing to happen before the crash happens
# so that they can pick up from the *exact* state it left off at.
# We do this by failing after a delay of 0.3s > TUNE_GLOBAL_CHECKPOINT_S
os.environ["TUNE_GLOBAL_CHECKPOINT_S"] = "0.1"
ray.init(num_cpus=2)
from ray.tune import register_trainable
register_trainable("trainable", MyTrainableClass)
def tearDown(self):
os.environ.pop("TUNE_GLOBAL_CHECKPOINT_S")
os.environ.pop("TUNE_MAX_PENDING_TRIALS_PG", None)
shutil.rmtree(self.logdir)
ray.shutdown()
def testFailResumeGridSearch(self):
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "1"
config = dict(
num_samples=3,
fail_fast=True,
config={
"test": tune.grid_search([1, 2, 3]),
"test2": tune.grid_search([1, 2, 3]),
},
stop={"training_iteration": 2},
name="testFailResumeGridSearch",
verbose=1,
)
with self.assertRaises(RuntimeError):
tune.run("trainable", callbacks=[self.FailureInjectorCallback()], **config)
analysis = tune.run(
"trainable", resume=True, callbacks=[self.CheckStateCallback()], **config
)
assert len(analysis.trials) == 27
test_counter = Counter([t.config["test"] for t in analysis.trials])
assert all(v == 9 for v in test_counter.values())
test2_counter = Counter([t.config["test2"] for t in analysis.trials])
assert all(v == 9 for v in test2_counter.values())
# Unfinished trials' resources should be updated.
def testResourceUpdateInResume(self):
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "1"
config = dict(
num_samples=3,
fail_fast=True,
config={
"test": tune.grid_search([1, 2, 3]),
"test2": tune.grid_search([1, 2, 3]),
},
stop={"training_iteration": 2},
name="testResourceUpdateInResume",
verbose=1,
)
with self.assertRaises(RuntimeError):
tune.run(
"trainable",
callbacks=[
self.FailureInjectorCallback(),
self.CheckTrialResourcesCallback(1),
],
**config,
)
analysis = tune.run(
"trainable",
resume=True,
resources_per_trial={"cpu": 2},
callbacks=[self.CheckTrialResourcesCallback(2)],
**config,
)
assert len(analysis.trials) == 27
@mock.patch.dict(os.environ, {"TUNE_MAX_PENDING_TRIALS_PG": "1"})
def testConfigUpdateInResume(self):
class FakeDataset:
def __init__(self, name):
self.name = name
config = dict(
num_samples=1,
fail_fast=True,
config={
"test": tune.grid_search(
[FakeDataset("1"), FakeDataset("2"), FakeDataset("3")]
),
"test2": tune.grid_search(
[
FakeDataset("4"),
FakeDataset("5"),
FakeDataset("6"),
FakeDataset("7"),
]
),
},
stop={"training_iteration": 2},
name="testConfigUpdateInResume",
verbose=1,
)
with self.assertRaises(RuntimeError):
tune.run(
"trainable",
callbacks=[
self.FailureInjectorCallback(num_trials=1),
self.CheckTrialResourcesCallback(1),
],
**config,
)
config["config"] = {
"test": tune.grid_search(
[FakeDataset("8"), FakeDataset("9"), FakeDataset("10")]
),
"test2": tune.grid_search(
[
FakeDataset("11"),
FakeDataset("12"),
FakeDataset("13"),
FakeDataset("14"),
]
),
}
analysis = tune.run(
"trainable",
resume=True,
**config,
)
assert len(analysis.trials) == 12
for t in analysis.trials:
# Make sure that test and test2 are updated.
assert t.config["test"].name in ["8", "9", "10"]
assert t.config["test2"].name in ["11", "12", "13", "14"]
def testFailResumeWithPreset(self):
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "1"
search_alg = BasicVariantGenerator(
points_to_evaluate=[{"test": -1, "test2": -1}, {"test": -1}, {"test2": -1}]
)
config = dict(
num_samples=3 + 3, # 3 preset, 3 samples
fail_fast=True,
config={
"test": tune.grid_search([1, 2, 3]),
"test2": tune.grid_search([1, 2, 3]),
},
stop={"training_iteration": 2},
name="testFailResumeWithPreset",
verbose=1,
)
with self.assertRaises(RuntimeError):
tune.run(
"trainable",
callbacks=[self.FailureInjectorCallback(5)],
search_alg=search_alg,
**config,
)
print("---- RESTARTING RUN ----")
analysis = tune.run(
"trainable",
resume=True,
callbacks=[self.CheckStateCallback(expected_trials=5)],
search_alg=search_alg,
**config,
)
assert len(analysis.trials) == 34
test_counter = Counter([t.config["test"] for t in analysis.trials])
assert test_counter.pop(-1) == 4
assert all(v == 10 for v in test_counter.values())
test2_counter = Counter([t.config["test2"] for t in analysis.trials])
assert test2_counter.pop(-1) == 4
assert all(v == 10 for v in test2_counter.values())
def testFailResumeAfterPreset(self):
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "1"
search_alg = BasicVariantGenerator(
points_to_evaluate=[{"test": -1, "test2": -1}, {"test": -1}, {"test2": -1}]
)
config = dict(
num_samples=3 + 3, # 3 preset, 3 samples
fail_fast=True,
config={
"test": tune.grid_search([1, 2, 3]),
"test2": tune.grid_search([1, 2, 3]),
},
stop={"training_iteration": 2},
name="testFailResumeAfterPreset",
verbose=1,
)
with self.assertRaises(RuntimeError):
tune.run(
"trainable",
callbacks=[self.FailureInjectorCallback(15)],
search_alg=search_alg,
**config,
)
print("---- RESTARTING RUN ----")
analysis = tune.run(
"trainable",
resume=True,
callbacks=[self.CheckStateCallback(expected_trials=15)],
search_alg=search_alg,
**config,
)
assert len(analysis.trials) == 34
test_counter = Counter([t.config["test"] for t in analysis.trials])
assert test_counter.pop(-1) == 4
assert all(v == 10 for v in test_counter.values())
test2_counter = Counter([t.config["test2"] for t in analysis.trials])
assert test2_counter.pop(-1) == 4
assert all(v == 10 for v in test2_counter.values())
def testMultiExperimentFail(self):
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "1"
experiments = []
for i in range(3):
experiments.append(
tune.Experiment(
run=MyTrainableClass,
name="testMultiExperimentFail",
num_samples=2,
config={
"test": tune.grid_search([1, 2, 3]),
},
stop={"training_iteration": 1},
)
)
with self.assertRaises(RuntimeError):
tune.run(
experiments,
callbacks=[self.FailureInjectorCallback(10)],
fail_fast=True,
)
analysis = tune.run(
experiments,
resume=True,
callbacks=[self.CheckStateCallback(expected_trials=10)],
fail_fast=True,
)
assert len(analysis.trials) == 18
def testWarningLargeGrid(self):
config = dict(
num_samples=3,
fail_fast=True,
config={
"test": tune.grid_search(list(range(20))),
"test2": tune.grid_search(list(range(20))),
"test3": tune.grid_search(list(range(20))),
"test4": tune.grid_search(list(range(20))),
"test5": tune.grid_search(list(range(20))),
},
stop={"training_iteration": 2},
name="testWarningLargeGrid",
verbose=1,
)
with self.assertWarnsRegex(UserWarning, "exceeds the serialization threshold"):
with self.assertRaises(RuntimeError):
tune.run(
"trainable", callbacks=[self.FailureInjectorCallback(10)], **config
)
class TuneExampleTest(unittest.TestCase):
def setUp(self):
ray.init(num_cpus=2)
def tearDown(self):
ray.shutdown()
def testPBTKeras(self):
from tensorflow.keras.datasets import cifar10
from ray.tune.examples.pbt_tune_cifar10_with_keras import Cifar10Model
cifar10.load_data()
validate_save_restore(Cifar10Model)
def testPyTorchMNIST(self):
from torchvision import datasets
from ray.tune.examples.mnist_pytorch_trainable import TrainMNIST
datasets.MNIST("~/data", train=True, download=True)
validate_save_restore(TrainMNIST)
def testHyperbandExample(self):
validate_save_restore(MyTrainableClass)
def testAsyncHyperbandExample(self):
validate_save_restore(MyTrainableClass)
class AutoInitTest(unittest.TestCase):
def testTuneRestore(self):
self.assertFalse(ray.is_initialized())
tune.run(MyTrainableClass, name="TestAutoInit", stop={"training_iteration": 1})
self.assertTrue(ray.is_initialized())
def tearDown(self):
ray.shutdown()
class SearcherTest(unittest.TestCase):
class MockSearcher(Searcher):
def __init__(self, data):
self.data = data
def save(self, path):
with open(path, "w") as f:
f.write(self.data)
def restore(self, path):
with open(path, "r") as f:
self.data = f.read()
def testSaveRestoreDir(self):
tmpdir = tempfile.mkdtemp()
original_data = "hello-its-me"
searcher = self.MockSearcher(original_data)
searcher.save_to_dir(tmpdir)
searcher_2 = self.MockSearcher("no-its-not-me")
searcher_2.restore_from_dir(tmpdir)
assert searcher_2.data == original_data
class WorkingDirectoryTest(unittest.TestCase):
def testWorkingDir(self):
"""Trainables should know the original working dir through env variable."""
os.environ.pop("TUNE_ORIG_WORKING_DIR", None)
working_dir = os.getcwd()
def f(config):
assert os.environ.get("TUNE_ORIG_WORKING_DIR") == working_dir
ray.init(num_cpus=1)
tune.run(f)
ray.shutdown()
class TrainableCrashWithFailFast(unittest.TestCase):
def test(self):
"""Trainable crashes with fail_fast flag and the original crash message
should bubble up."""
def f(config):
ray.tune.report({"a": 1})
time.sleep(0.1)
raise RuntimeError("Error happens in trainable!!")
with self.assertRaisesRegex(RayTaskError, "Error happens in trainable!!"):
tune.run(f, fail_fast=TuneController.RAISE)
@pytest.mark.parametrize(
"trial_config", [{}, {"attr": 4}, {"nested": {"key": "value"}}]
)
def test_trial_last_result_restore(trial_config):
metrics = {"metric1": 4, "nested2": {"metric3": 6}}
metrics["config"] = trial_config
trial = Trial(trainable_name="stub", config=trial_config, stub=True)
trial.update_last_result(metrics)
result = _TrainingResult(
checkpoint=Checkpoint(path="file:///tmp/no_data"), metrics=metrics
)
trial.temporary_state.restoring_from = result
trial.on_restore()
assert trial.run_metadata.last_result == metrics
def test_stacktrace():
"""Test proper stacktrace is printed for RayTaskError."""
CMD = """
from ray import tune
def train_fn(config):
raise Exception("Inducing exception for testing purposes.")
tune.run(train_fn, num_samples=1)
"""
with pytest.raises(subprocess.CalledProcessError) as exc_info:
run_string_as_driver(CMD)
assert "Inducing exception for testing purposes." in exc_info.value.output.decode()
@pytest.mark.parametrize(
"resume",
[
True,
"AUTO",
"AUTO+ERRORED",
"AUTO+ERRORED_ONLY",
"AUTO+RESTART_ERRORED",
"AUTO+RESTART_ERRORED_ONLY",
],
)
def test_resume_options(tmp_path, resume):
tmp_path.joinpath("dummy_ckpt").mkdir()
def train_fn(config):
checkpoint = ray.tune.get_checkpoint()
if not checkpoint:
ray.tune.report(
{"finish_marker": False},
checkpoint=Checkpoint.from_directory(tmp_path / "dummy_ckpt"),
)
raise RuntimeError("failing on the first run!!")
ray.tune.report({"finish_marker": True})
analysis = tune.run(
train_fn,
storage_path=str(tmp_path),
name="test_resume_options",
raise_on_failed_trial=False,
)
results = ray.tune.ResultGrid(analysis)
assert not results[0].metrics.get("finish_marker", False)
analysis = tune.run(
train_fn,
storage_path=str(tmp_path),
name="test_resume_options",
resume=resume,
raise_on_failed_trial=False,
)
results = ray.tune.ResultGrid(analysis)
if resume in [True, "AUTO", "AUTO+RESTART_ERRORED", "AUTO+RESTART_ERRORED_ONLY"]:
# These options either don't resume the errored trial,
# or restart it without a checkpoint --> leading to the RuntimeError again
assert not results[0].metrics.get("finish_marker")
else:
assert results[0].metrics.get("finish_marker")
# For some reason, different tests are coupled through tune.registry.
# After running `ResourceExhaustedTest`, there is always a super huge `training_func` to
# be put through GCS, which will fail subsequent tests.
# tldr, make sure that this test is the last test in the file.
class ResourceExhaustedTest(unittest.TestCase):
def test_resource_exhausted_info(self):
"""This is to test if helpful information is displayed when
the objects captured in trainable/training function are too
large and RESOURCES_EXHAUSTED error of gRPC is triggered."""
a_large_array = []
for _ in range(50):
a_large_array.append(np.random.rand(400, 4096))
def training_func(config):
del config # unused var
for item in a_large_array:
assert item
with self.assertRaisesRegex(
TuneError,
"The Trainable/training function is too large for grpc resource limit.",
):
tune.run(training_func)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__] + sys.argv[1:]))