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

527 lines
16 KiB
Python

import inspect
import os
import sys
import tempfile
import time
from pathlib import Path
from typing import Callable
import pytest
import ray
from ray import logger, tune
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
from ray.tune import CheckpointConfig, Trainable, register_trainable, run_experiments
from ray.tune.error import TuneError
from ray.tune.result_grid import ResultGrid
from ray.tune.schedulers.trial_scheduler import FIFOScheduler, TrialScheduler
from ray.tune.tune import _check_mixin
@pytest.fixture
def ray_start_1_cpu():
address_info = ray.init(num_cpus=1)
os.environ["TUNE_STATE_REFRESH_PERIOD"] = "0.1"
yield address_info
ray.shutdown()
os.environ.pop("TUNE_STATE_REFRESH_PERIOD", None)
@pytest.fixture
def ray_start_2_cpus():
address_info = ray.init(num_cpus=2)
yield address_info
ray.shutdown()
@pytest.fixture
def ray_start_4_cpus_extra():
address_info = ray.init(num_cpus=4, resources={"extra": 4})
yield address_info
ray.shutdown()
class FrequentPausesScheduler(FIFOScheduler):
def on_trial_result(self, tune_controller, trial, result):
return TrialScheduler.PAUSE
class MyResettableClass(Trainable):
def setup(self, config):
self.config = config
self.num_resets = 0
self.iter = 0
self.msg = config.get("message", None)
self.sleep = int(config.get("sleep", 0))
self.fail = config.get("fail", False)
def step(self):
self.iter += 1
if self.msg:
print("PRINT_STDOUT: {}".format(self.msg))
print("PRINT_STDERR: {}".format(self.msg), file=sys.stderr)
logger.info("LOG_STDERR: {}".format(self.msg))
if self.fail:
raise RuntimeError("Failing")
if self.sleep:
time.sleep(self.sleep)
return {
"id": self.config.get("id", -1),
"num_resets": self.num_resets,
"done": self.iter > 1,
"iter": self.iter,
}
def save_checkpoint(self, chkpt_dir):
return {"iter": self.iter}
def load_checkpoint(self, item):
self.iter = item["iter"]
def reset_config(self, new_config):
if "fake_reset_not_supported" in self.config:
return False
self.num_resets += 1
self.iter = 0
self.msg = new_config.get("message", None)
self.fail = new_config.get("fail", False)
return True
@classmethod
def default_resource_request(cls, config):
required_resources = config.get("required_resources", None)
if required_resources:
return required_resources
return None
def train_fn(config):
# Determine whether or not we reset to a new trial
marker_dir = config.get("marker_dir")
num_resets = 0
marker = Path(marker_dir) / f"{os.getpid()}.txt"
if marker.exists():
num_resets = int(marker.read_text()) + 1
checkpoint = tune.get_checkpoint()
it = load_dict_checkpoint(checkpoint)["iter"] if checkpoint else 0
msg = config.get("message", None)
sleep = int(config.get("sleep", 0))
fail = config.get("fail", False)
while it < 2:
it += 1
if msg:
print("PRINT_STDOUT: {}".format(msg))
print("PRINT_STDERR: {}".format(msg), file=sys.stderr)
logger.info("LOG_STDERR: {}".format(msg))
if fail:
raise RuntimeError("Failing")
# Dump the current config
marker.write_text(str(num_resets))
if sleep:
time.sleep(sleep)
metrics = {
"id": config.get("id", 0),
"num_resets": num_resets,
"iter": it,
"done": it > 1,
}
if config.get("save_checkpoint", True):
with create_dict_checkpoint({"iter": it}) as checkpoint:
tune.report(metrics, checkpoint=checkpoint)
else:
tune.report(metrics, checkpoint=checkpoint)
@pytest.fixture(params=["function", "class"])
def trainable(request):
"""Fixture that sets up a function/class trainable for testing.
Make sure this fixture comes BEFORE the ray.init fixture in the arguments
so that the env var is propagated to workers correctly."""
trainable_type = request.param
if trainable_type == "function":
yield train_fn
elif trainable_type == "class":
yield MyResettableClass
else:
raise NotImplementedError
def _run_trials_with_frequent_pauses(trainable, reuse=False, **kwargs):
tempdir = tempfile.mkdtemp()
marker_dir = Path(tempdir)
analysis = tune.run(
trainable,
num_samples=1,
config={
"id": tune.grid_search([0, 1, 2, 3]),
"marker_dir": marker_dir,
},
reuse_actors=reuse,
scheduler=FrequentPausesScheduler(),
verbose=0,
**kwargs,
)
return analysis
def test_trial_reuse_disabled(trainable, ray_start_1_cpu):
"""Test that reuse=False disables actor re-use.
Setup: Pass `reuse_actors=False` to tune.run()
We assert the `num_resets` of each trainable class to be 0 (no reuse).
"""
analysis = _run_trials_with_frequent_pauses(trainable, reuse=False)
trials = analysis.trials
assert [t.last_result["id"] for t in trials] == [0, 1, 2, 3]
assert [t.last_result["iter"] for t in trials] == [2, 2, 2, 2]
assert [t.last_result["num_resets"] for t in trials] == [0, 0, 0, 0]
def test_trial_reuse_enabled(trainable, ray_start_1_cpu):
"""Test that reuse=True enables actor re-use.
Setup: Pass `reuse_actors=True` to tune.run()
We assert the `num_resets` of each trainable class to be 4, 5, 6, and 7,
respectively:
- Each trial runs for 2 iterations
- Only one trial can run at a time
- After each iteration, trials are paused and actors cached for reuse
- Thus, the first trial finishes after 4 resets, the second after 5, etc.
"""
analysis = _run_trials_with_frequent_pauses(trainable, reuse=True)
trials = analysis.trials
assert [t.last_result["id"] for t in trials] == [0, 1, 2, 3]
assert [t.last_result["iter"] for t in trials] == [2, 2, 2, 2]
assert [t.last_result["num_resets"] for t in trials] == [4, 5, 6, 7]
def test_trial_reuse_with_failing(trainable, ray_start_1_cpu, tmp_path):
"""Test that failing actors won't be reused.
- 1 trial can run at a time
- Some trials are failing
- We assert that trials after failing trials are scheduled on fresh actors
(num_resets = 0)
- We assert that trials after successful trials are schedule on reused actors
(num_reset = last_num_resets + 1)
"""
fail = [False, True, False, False, True, True, False, False, False]
trials = tune.run(
trainable,
reuse_actors=True,
config={
"id": tune.grid_search(list(range(9))),
"fail": tune.sample_from(lambda config: fail[config["id"]]),
"marker_dir": tmp_path,
},
raise_on_failed_trial=False,
).trials
assert [t.last_result.get("iter") for t in trials] == [
2,
None,
2,
2,
None,
None,
2,
2,
2,
]
assert [t.last_result.get("num_resets") for t in trials] == [
0,
None,
0,
1,
None,
None,
0,
1,
2,
]
def test_reuse_enabled_error(ray_start_1_cpu):
"""Test that a class without reset() enabled throws an error on actor reuse."""
with pytest.raises(TuneError):
run_experiments(
{
"foo": {
"run": MyResettableClass,
"max_failures": 1,
"num_samples": 1,
"config": {
"id": tune.grid_search([0, 1, 2, 3]),
"fake_reset_not_supported": True,
},
}
},
reuse_actors=True,
scheduler=FrequentPausesScheduler(),
)
def test_trial_reuse_log_to_file(trainable, ray_start_1_cpu, tmp_path):
"""Check that log outputs from trainables are correctly stored with actor reuse.
We run two trials with actor reuse. When the actor is reused, we expect
the log output to be written to the log file of the new trial - i.e. we expect
that the old trial logfile is closed and a new one is open.
"""
register_trainable("foo2", trainable)
messages = ["First", "Second"]
# Log to default files
[trial1, trial2] = tune.run(
"foo2",
config={
"id": tune.grid_search(list(range(2))),
"message": tune.sample_from(lambda config: messages[config["id"]]),
"marker_dir": tmp_path,
},
log_to_file=True,
scheduler=FrequentPausesScheduler(),
reuse_actors=True,
).trials
def get_trial_logfiles(trial):
return (
os.path.join(trial.storage.trial_working_directory, "stdout"),
os.path.join(trial.storage.trial_working_directory, "stderr"),
)
# Check trial 1
assert trial1.last_result["num_resets"] == 2
[stdout, stderr] = get_trial_logfiles(trial1)
assert os.path.exists(stdout)
assert os.path.exists(stderr)
# We expect that only "First" output is found in the first trial output
with open(stdout, "rt") as fp:
content = fp.read()
assert "PRINT_STDOUT: First" in content
assert "PRINT_STDOUT: Second" not in content
with open(stderr, "rt") as fp:
content = fp.read()
assert "PRINT_STDERR: First" in content
assert "LOG_STDERR: First" in content
assert "PRINT_STDERR: Second" not in content
assert "LOG_STDERR: Second" not in content
# Check trial 2
assert trial2.last_result["num_resets"] == 3
[stdout, stderr] = get_trial_logfiles(trial2)
assert os.path.exists(stdout)
assert os.path.exists(stderr)
# We expect that only "Second" output is found in the first trial output
with open(stdout, "rt") as fp:
content = fp.read()
assert "PRINT_STDOUT: Second" in content
assert "PRINT_STDOUT: First" not in content
with open(stderr, "rt") as fp:
content = fp.read()
assert "PRINT_STDERR: Second" in content
assert "LOG_STDERR: Second" in content
assert "PRINT_STDERR: First" not in content
assert "LOG_STDERR: First" not in content
def test_multi_trial_reuse(trainable, ray_start_4_cpus_extra, monkeypatch, tmp_path):
"""Test that actors from multiple trials running in parallel will be reused.
- 2 trials can run at the same time
- Trial 3 will be scheduled after trial 1 succeeded, so will reuse actor
- Trial 4 will be scheduled after trial 2 succeeded, so will reuse actor
"""
monkeypatch.setenv("TUNE_MAX_PENDING_TRIALS_PG", "2")
register_trainable("foo2", trainable)
messages = ["First", "Second", "Third", "Fourth"]
# We sleep here for one second so that the third actor
# does not finish training before the fourth can be scheduled.
# This helps ensure that both remote runners are re-used and
# not just one.
[trial1, trial2, trial3, trial4] = tune.run(
"foo2",
config={
"id": tune.grid_search(list(range(4))),
"message": tune.sample_from(lambda config: messages[config["id"]]),
"marker_dir": tmp_path,
"sleep": 2,
},
reuse_actors=True,
resources_per_trial={"cpu": 2},
).trials
assert trial3.last_result["num_resets"] == 1
assert trial4.last_result["num_resets"] == 1
def test_multi_trial_reuse_with_failing(
trainable, ray_start_4_cpus_extra, monkeypatch, tmp_path
):
"""Test that failing trial's actors are not reused.
- 2 trials can run at the same time
- Trial 1 succeeds, trial 2 fails
- Trial 3 will be scheduled after trial 2 failed, so won't reuse actor
- Trial 4 will be scheduled after trial 1 succeeded, so will reuse actor
"""
monkeypatch.setenv("TUNE_MAX_PENDING_TRIALS_PG", "2")
register_trainable("foo2", trainable)
[trial1, trial2, trial3, trial4] = tune.run(
"foo2",
config={
"fail": tune.grid_search([False, True, False, False]),
"marker_dir": tmp_path,
"sleep": 2,
},
reuse_actors=True,
resources_per_trial={"cpu": 2},
raise_on_failed_trial=False,
).trials
assert trial1.last_result["num_resets"] == 0
assert trial3.last_result["num_resets"] == 0
assert trial4.last_result["num_resets"] == 1
def test_multi_trial_reuse_one_by_one(trainable, ray_start_4_cpus_extra, tmp_path):
"""Test that we still reuse actors even if we run with concurrency = 1.
- Run 6 trials, but only 1 concurrent at the time
- This means there won't be any PENDING trials until the trial completed
- We still want to reuse actors
"""
register_trainable("foo2", trainable)
trials = tune.run(
"foo2",
config={"id": -1, "marker_dir": tmp_path},
reuse_actors=True,
num_samples=6,
max_concurrent_trials=1,
).trials
assert sorted([t.last_result["num_resets"] for t in trials]) == [0, 1, 2, 3, 4, 5]
def test_multi_trial_reuse_heterogeneous(ray_start_4_cpus_extra):
"""Test that actors with heterogeneous resource requirements are reused efficiently.
- Run 6 trials in total
- Only 1 trial can run at the same time
- Trials 1 and 6, 2 and 4, and 3 and 5, have the same resource request, respectively
- Assert that trials 4, 5, and 6 re-use their respective previous actors
"""
# We need to set this to 6 so that all trials will be scheduled and actors will
# be correctly cached.
os.environ["TUNE_MAX_PENDING_TRIALS_PG"] = "6"
register_trainable("foo2", MyResettableClass)
trials = tune.run(
"foo2",
config={
# The extra resources are selected so that only any 1 placement group
# can be scheduled at the same time at all times (to force sequential
# execution of trials)
"required_resources": tune.grid_search(
[
{"cpu": 4, "custom_resources": {"extra": 4}},
{"cpu": 2, "custom_resources": {"extra": 4}},
{"cpu": 1, "custom_resources": {"extra": 4}},
{"cpu": 2, "custom_resources": {"extra": 4}},
{"cpu": 1, "custom_resources": {"extra": 4}},
{"cpu": 4, "custom_resources": {"extra": 4}},
]
),
"id": -1,
},
reuse_actors=True,
).trials
# Actors may be re-used in a different order as the staged_trials set is unsorted
assert sorted([t.last_result["num_resets"] for t in trials]) == [0, 0, 0, 1, 1, 1]
def test_detect_reuse_mixins():
class DummyMixin:
pass
def dummy_mixin(func: Callable):
func.__mixins__ = (DummyMixin,)
return func
def train_fn(config):
pass
assert not _check_mixin(train_fn)
assert _check_mixin(dummy_mixin(train_fn))
class MyTrainable(Trainable):
pass
assert not _check_mixin(MyTrainable)
assert _check_mixin(dummy_mixin(MyTrainable))
def test_remote_trial_dir_with_reuse_actors(trainable, ray_start_2_cpus, tmp_path):
"""Check that the trainable has its remote directory set to the right
location, when new trials get swapped in on actor reuse.
Each trial runs for 2 iterations, with checkpoint_frequency=1, so each
remote trial dir should have 2 checkpoints.
"""
tmp_target = str(tmp_path / "upload_dir")
exp_name = "remote_trial_dir_update_on_actor_reuse"
def get_remote_trial_dir(trial_id: int):
return os.path.join(tmp_target, exp_name, str(trial_id))
analysis = _run_trials_with_frequent_pauses(
trainable,
reuse=True,
max_concurrent_trials=2,
name=exp_name,
storage_path=f"file://{tmp_target}",
trial_dirname_creator=lambda t: str(t.config.get("id")),
checkpoint_config=CheckpointConfig(
checkpoint_frequency=1 if inspect.isclass(trainable) else 0
),
)
result_grid = ResultGrid(analysis)
assert not result_grid.errors
# Check that each remote trial dir has 2 checkpoints.
for result in result_grid:
trial_id = result.config["id"]
remote_dir = get_remote_trial_dir(trial_id)
num_checkpoints = len(
[file for file in os.listdir(remote_dir) if file.startswith("checkpoint_")]
)
assert num_checkpoints == 2
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))