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
+53
View File
@@ -0,0 +1,53 @@
from dataclasses import dataclass
from ray.air.config import (
CheckpointConfig as _CheckpointConfig,
FailureConfig as _FailureConfig,
RunConfig as _RunConfig,
)
from ray.train.constants import (
V2_MIGRATION_GUIDE_MESSAGE,
_v2_migration_warnings_enabled,
)
from ray.train.utils import _copy_doc, _log_deprecation_warning
# NOTE: This is just a pass-through wrapper around `ray.tune.RunConfig`
# in order to detect whether the import module was correct (e.g. `ray.tune.RunConfig`).
@dataclass
@_copy_doc(_CheckpointConfig)
class CheckpointConfig(_CheckpointConfig):
pass
@dataclass
@_copy_doc(_FailureConfig)
class FailureConfig(_FailureConfig):
pass
@dataclass
@_copy_doc(_RunConfig)
class RunConfig(_RunConfig):
def __post_init__(self):
self.checkpoint_config = self.checkpoint_config or CheckpointConfig()
self.failure_config = self.failure_config or FailureConfig()
super().__post_init__()
if not isinstance(self.checkpoint_config, CheckpointConfig):
if _v2_migration_warnings_enabled():
_log_deprecation_warning(
"The `CheckpointConfig` class should be imported from `ray.tune` "
"when passing it to the Tuner. Please update your imports."
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
if not isinstance(self.failure_config, FailureConfig):
if _v2_migration_warnings_enabled():
_log_deprecation_warning(
"The `FailureConfig` class should be imported from `ray.tune` "
"when passing it to the Tuner. Please update your imports."
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
@@ -0,0 +1,33 @@
import contextlib
import traceback
import ray
def _deserialize_and_fully_execute_if_needed(serialized_ds: bytes):
ds = ray.data.Dataset.deserialize_lineage(serialized_ds)
return ds
def _reduce(ds: ray.data.Dataset):
tb_list = traceback.format_list(traceback.extract_stack())
_already_in_out_of_band_serialization = False
for tb in tb_list:
# TODO(xwjiang): Let's make this less hacky.
if "serialize_lineage" in tb:
_already_in_out_of_band_serialization = True
break
if not _already_in_out_of_band_serialization and ds.has_serializable_lineage():
return _deserialize_and_fully_execute_if_needed, (ds.serialize_lineage(),)
else:
return ds.__reduce__()
@contextlib.contextmanager
def out_of_band_serialize_dataset():
context = ray._private.worker.global_worker.get_serialization_context()
try:
context._register_cloudpickle_reducer(ray.data.Dataset, _reduce)
yield
finally:
context._unregister_cloudpickle_reducer(ray.data.Dataset)
+244
View File
@@ -0,0 +1,244 @@
import hashlib
from collections import defaultdict
from typing import Any, Dict, Tuple
from ray.tune.search.sample import Categorical, Domain, Function
from ray.tune.search.variant_generator import assign_value
from ray.util.annotations import DeveloperAPI
ID_HASH_LENGTH = 8
def create_resolvers_map():
return defaultdict(list)
def _id_hash(path_tuple):
"""Compute a hash for the specific placeholder based on its path."""
return hashlib.sha256(str(path_tuple).encode("utf-8")).hexdigest()[:ID_HASH_LENGTH]
class _FunctionResolver:
"""Replaced value for function typed objects."""
TOKEN = "__fn_ph"
def __init__(self, hash, fn):
self.hash = hash
self._fn = fn
def resolve(self, config: Dict):
"""Some functions take a resolved spec dict as input.
Note: Function placeholders are independently sampled during
resolution. Therefore their random states are not restored.
"""
return self._fn.sample(config=config)
def get_placeholder(self) -> str:
return (self.TOKEN, self.hash)
class _RefResolver:
"""Replaced value for all other non-primitive objects."""
TOKEN = "__ref_ph"
def __init__(self, hash, value):
self.hash = hash
self._value = value
def resolve(self):
return self._value
def get_placeholder(self) -> str:
return (self.TOKEN, self.hash)
def _is_primitive(x):
"""Returns True if x is a primitive type.
Primitive types are int, float, str, bool, and None.
"""
return isinstance(x, (int, float, str, bool)) or x is None
@DeveloperAPI
def inject_placeholders(
config: Any,
resolvers: defaultdict,
id_prefix: Tuple = (),
path_prefix: Tuple = (),
) -> Dict:
"""Replaces reference objects contained by a config dict with placeholders.
Given a config dict, this function replaces all reference objects contained
by this dict with placeholder strings. It recursively expands nested dicts
and lists, and properly handles Tune native search objects such as Categorical
and Function.
This makes sure the config dict only contains primitive typed values, which
can then be handled by different search algorithms.
A few details about id_prefix and path_prefix. Consider the following config,
where "param1" is a simple grid search of 3 tuples.
config = {
"param1": tune.grid_search([
(Cat, None, None),
(None, Dog, None),
(None, None, Fish),
]),
}
We will replace the 3 objects contained with placeholders. And after trial
expansion, the config may look like this:
config = {
"param1": (None, (placeholder, hash), None)
}
Now you need 2 pieces of information to resolve the placeholder. One is the
path of ("param1", 1), which tells you that the first element of the tuple
under "param1" key is a placeholder that needs to be resolved.
The other is the mapping from the placeholder to the actual object. In this
case hash -> Dog.
id and path prefixes serve exactly this purpose here. The difference between
these two is that id_prefix is the location of the value in the pre-injected
config tree. So if a value is the second option in a grid_search, it gets an
id part of 1. Injected placeholders all get unique id prefixes. path prefix
identifies a placeholder in the expanded config tree. So for example, all
options of a single grid_search will get the same path prefix. This is how
we know which location has a placeholder to be resolved in the post-expansion
tree.
Args:
config: The config dict to replace references in.
resolvers: A dict from path to replaced objects.
id_prefix: The prefix to prepend to id every single placeholders.
path_prefix: The prefix to prepend to every path identifying
potential locations of placeholders in an expanded tree.
Returns:
The config with all references replaced.
"""
if isinstance(config, dict) and "grid_search" in config and len(config) == 1:
config["grid_search"] = [
# Different options gets different id prefixes.
# But we should omit appending to path_prefix because after expansion,
# this level will not be there.
inject_placeholders(choice, resolvers, id_prefix + (i,), path_prefix)
for i, choice in enumerate(config["grid_search"])
]
return config
elif isinstance(config, dict):
return {
k: inject_placeholders(v, resolvers, id_prefix + (k,), path_prefix + (k,))
for k, v in config.items()
}
elif isinstance(config, list):
return [
inject_placeholders(elem, resolvers, id_prefix + (i,), path_prefix + (i,))
for i, elem in enumerate(config)
]
elif isinstance(config, tuple):
return tuple(
inject_placeholders(elem, resolvers, id_prefix + (i,), path_prefix + (i,))
for i, elem in enumerate(config)
)
elif _is_primitive(config):
# Primitive types.
return config
elif isinstance(config, Categorical):
config.categories = [
# Different options gets different id prefixes.
# But we should omit appending to path_prefix because after expansion,
# this level will not be there.
inject_placeholders(choice, resolvers, id_prefix + (i,), path_prefix)
for i, choice in enumerate(config.categories)
]
return config
elif isinstance(config, Function):
# Function type.
id_hash = _id_hash(id_prefix)
v = _FunctionResolver(id_hash, config)
resolvers[path_prefix].append(v)
return v.get_placeholder()
elif not isinstance(config, Domain):
# Other non-search space reference objects, dataset, actor handle, etc.
id_hash = _id_hash(id_prefix)
v = _RefResolver(id_hash, config)
resolvers[path_prefix].append(v)
return v.get_placeholder()
else:
# All the other cases, do nothing.
return config
def _get_placeholder(config: Any, prefix: Tuple, path: Tuple):
if not path:
return prefix, config
key = path[0]
if isinstance(config, tuple):
if config[0] in (_FunctionResolver.TOKEN, _RefResolver.TOKEN):
# Found a matching placeholder.
# Note that we do not require that the full path are consumed before
# declaring a match. Because this placeholder may be part of a nested
# search space. For example, the following config:
# config = {
# "param1": tune.grid_search([
# tune.grid_search([Object1, 2, 3]),
# tune.grid_search([Object2, 5, 6]),
# ]),
# }
# will result in placeholders under path ("param1", 0, 0).
# After expansion though, the choosen placeholder will live under path
# ("param1", 0) like this: config = {"param1": (Placeholder1, 2, 3)}
return prefix, config
elif key < len(config):
return _get_placeholder(
config[key], prefix=prefix + (path[0],), path=path[1:]
)
elif (isinstance(config, dict) and key in config) or (
isinstance(config, list) and key < len(config)
):
# Expand config tree recursively.
return _get_placeholder(config[key], prefix=prefix + (path[0],), path=path[1:])
# Can not find a matching placeholder.
return None, None
@DeveloperAPI
def resolve_placeholders(config: Any, replaced: defaultdict):
"""Replaces placeholders contained by a config dict with the original values.
Args:
config: The config to replace placeholders in.
replaced: A dict from path to replaced objects.
"""
def __resolve(resolver_type, args):
for path, resolvers in replaced.items():
assert resolvers
if not isinstance(resolvers[0], resolver_type):
continue
prefix, ph = _get_placeholder(config, (), path)
if not ph:
# Represents an unchosen value. Just skip.
continue
for resolver in resolvers:
if resolver.hash != ph[1]:
continue
# Found the matching resolver.
assign_value(config, prefix, resolver.resolve(*args))
# RefResolvers first.
__resolve(_RefResolver, args=())
# Functions need to be resolved after RefResolvers, in case they are
# referencing values from the RefResolvers.
__resolve(_FunctionResolver, args=(config,))
+65
View File
@@ -0,0 +1,65 @@
from sklearn.datasets import load_breast_cancer
from ray import tune
from ray.data import Dataset, Datasource, ReadTask, read_datasource
from ray.data.block import BlockMetadata
from ray.tune.impl.utils import execute_dataset
# TODO(xwjiang): Enable this when Clark's out-of-band-serialization is landed.
class TestDatasource(Datasource):
def prepare_read(self, parallelism: int, **read_args):
import pyarrow as pa
def load_data():
data_raw = load_breast_cancer(as_frame=True)
dataset_df = data_raw["data"]
dataset_df["target"] = data_raw["target"]
return [pa.Table.from_pandas(dataset_df)]
meta = BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=None,
exec_stats=None,
)
return [ReadTask(load_data, meta)]
def gen_dataset_func() -> Dataset:
test_datasource = TestDatasource()
return read_datasource(test_datasource)
def test_grid_search():
ds1 = gen_dataset_func().lazy().map(lambda x: x)
ds2 = gen_dataset_func().lazy().map(lambda x: x)
assert not ds1._has_computed_output()
assert not ds2._has_computed_output()
param_space = {"train_dataset": tune.grid_search([ds1, ds2])}
execute_dataset(param_space)
executed_ds = param_space["train_dataset"]["grid_search"]
assert len(executed_ds) == 2
assert executed_ds[0]._has_computed_output()
assert executed_ds[1]._has_computed_output()
def test_choice():
ds1 = gen_dataset_func().lazy().map(lambda x: x)
ds2 = gen_dataset_func().lazy().map(lambda x: x)
assert not ds1._has_computed_output()
assert not ds2._has_computed_output()
param_space = {"train_dataset": tune.choice([ds1, ds2])}
execute_dataset(param_space)
executed_ds = param_space["train_dataset"].categories
assert len(executed_ds) == 2
assert executed_ds[0]._has_computed_output()
assert executed_ds[1]._has_computed_output()
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main(["-v", "-x", __file__]))
+698
View File
@@ -0,0 +1,698 @@
import copy
import io
import logging
import math
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Type,
Union,
)
import pyarrow.fs
import ray.cloudpickle as pickle
import ray.train
import ray.tune
from ray.air._internal.uri_utils import URI
from ray.air._internal.usage import AirEntrypoint
from ray.train._internal.storage import StorageContext, get_fs_and_path
from ray.train.constants import (
V2_MIGRATION_GUIDE_MESSAGE,
_v2_migration_warnings_enabled,
)
from ray.train.utils import _log_deprecation_warning
from ray.tune import (
Experiment,
ExperimentAnalysis,
ResumeConfig,
RunConfig,
TuneConfig,
TuneError,
)
from ray.tune.registry import is_function_trainable
from ray.tune.result_grid import ResultGrid
from ray.tune.trainable import Trainable
from ray.tune.tune import _Config, run
from ray.tune.utils import flatten_dict
from ray.util import inspect_serializability
if TYPE_CHECKING:
from ray.train.trainer import BaseTrainer
from ray.util.queue import Queue
_TUNER_PKL = "tuner.pkl"
_TRAINABLE_KEY = "_trainable"
_CONVERTED_TRAINABLE_KEY = "_converted_trainable"
_PARAM_SPACE_KEY = "_param_space"
_EXPERIMENT_ANALYSIS_KEY = "_experiment_analysis"
logger = logging.getLogger(__name__)
TrainableType = Union[str, Callable, Type[Trainable]]
TrainableTypeOrTrainer = Union[TrainableType, "BaseTrainer"]
class TunerInternal:
"""The real implementation behind external facing ``Tuner``.
The external facing ``Tuner`` multiplexes between local Tuner and remote Tuner
depending on whether in Ray client mode.
In Ray client mode, external ``Tuner`` wraps ``TunerInternal`` into a remote actor,
which is guaranteed to be placed on head node.
``TunerInternal`` can be constructed from fresh, in which case, ``trainable`` needs
to be provided, together with optional ``param_space``, ``tune_config`` and
``run_config``.
It can also be restored from a previous failed run (given ``restore_path``).
Args:
restore_path: The path from where the Tuner can be restored. If provided, None
of the rest args are needed.
storage_filesystem: A custom ``pyarrow.fs.FileSystem`` corresponding
to ``restore_path``. This may be necessary if the original
experiment used a custom filesystem.
resume_config: Resume config to configure which trials to continue.
trainable: The trainable to be tuned.
param_space: Search space of the tuning job.
One thing to note is that both preprocessor and dataset can be tuned here.
tune_config: Tuning algorithm specific configs.
Refer to ray.tune.tune_config.TuneConfig for more info.
run_config: Runtime configuration that is specific to individual trials.
If passed, this will overwrite the run config passed to the Trainer,
if applicable. Refer to ray.tune.RunConfig for more info.
_tuner_kwargs: Internal. Extra kwargs forwarded to ``tune.run`` when
this Tuner is fit.
_entrypoint: Internal. Tracks which user-facing entrypoint constructed
this Tuner so that warnings and errors can be specialized.
"""
def __init__(
self,
restore_path: str = None,
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
resume_config: Optional[ResumeConfig] = None,
trainable: Optional[TrainableTypeOrTrainer] = None,
param_space: Optional[Dict[str, Any]] = None,
tune_config: Optional[TuneConfig] = None,
run_config: Optional[RunConfig] = None,
_tuner_kwargs: Optional[Dict] = None,
_entrypoint: AirEntrypoint = AirEntrypoint.TUNER,
):
from ray.train.trainer import BaseTrainer
if isinstance(trainable, BaseTrainer):
if _v2_migration_warnings_enabled():
_log_deprecation_warning(
"The Ray Train + Ray Tune integration has been reworked. "
"Passing a Trainer to the Tuner is deprecated and will be removed "
"in a future release. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
run_config = self._choose_run_config(
tuner_run_config=run_config,
trainer=trainable,
param_space=param_space,
)
self._tune_config = tune_config or TuneConfig()
self._run_config = copy.copy(run_config) or RunConfig()
self._entrypoint = _entrypoint
# Restore from Tuner checkpoint.
if restore_path:
self._restore_from_path_or_uri(
path_or_uri=restore_path,
trainable=trainable,
overwrite_param_space=param_space,
resume_config=resume_config,
storage_filesystem=storage_filesystem,
)
return
# Start from fresh
if not trainable:
raise TuneError("You need to provide a trainable to tune.")
if self._entrypoint == AirEntrypoint.TUNER and not isinstance(
self._run_config, ray.tune.RunConfig
):
if _v2_migration_warnings_enabled():
_log_deprecation_warning(
"The `RunConfig` class should be imported from `ray.tune` "
"when passing it to the Tuner. Please update your imports. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
self.trainable = trainable
assert self.converted_trainable
self._validate_trainable(self.converted_trainable)
self.param_space = param_space
self._resume_config = None
self._is_restored = False
self._tuner_kwargs = copy.deepcopy(_tuner_kwargs) or {}
self._experiment_analysis = None
self._run_config.name = (
self._run_config.name
or StorageContext.get_experiment_dir_name(self.converted_trainable)
)
# The storage context here is only used to access the resolved
# storage fs and experiment path, in order to avoid duplicating that logic.
# This is NOT the storage context object that gets passed to remote workers.
storage = StorageContext(
storage_path=self._run_config.storage_path,
experiment_dir_name=self._run_config.name,
storage_filesystem=self._run_config.storage_filesystem,
)
fs = storage.storage_filesystem
fs.create_dir(storage.experiment_fs_path)
with fs.open_output_stream(
Path(storage.experiment_fs_path, _TUNER_PKL).as_posix()
) as f:
f.write(pickle.dumps(self.__getstate__()))
def get_run_config(self) -> RunConfig:
return self._run_config
# For Jupyter output with Ray Client
def set_run_config_and_remote_string_queue(
self, run_config: RunConfig, string_queue: "Queue"
):
self._run_config = run_config
self._tuner_kwargs["_remote_string_queue"] = string_queue
def clear_remote_string_queue(self):
self._tuner_kwargs.pop("_remote_string_queue", None)
def _expected_utilization(self, cpus_per_trial, cpus_total):
num_samples = self._tune_config.num_samples
if num_samples < 0: # TODO: simplify this in Tune
num_samples = math.inf
concurrent_trials = self._tune_config.max_concurrent_trials or 0
if concurrent_trials < 1: # TODO: simplify this in Tune
concurrent_trials = math.inf
actual_concurrency = min(
(
(cpus_total // cpus_per_trial) if cpus_per_trial else 0,
num_samples,
concurrent_trials,
)
)
return (actual_concurrency * cpus_per_trial) / (cpus_total + 0.001)
def _validate_trainable(
self,
trainable: TrainableType,
required_trainable_name: Optional[str] = None,
) -> None:
"""Determines whether or not the trainable is valid.
This includes checks on the serializability of the trainable, as well
asserting that the trainable name is as expected on restoration.
This trainable name validation is needed due to an implementation detail
where the trainable name (which is differently generated depending on
the trainable type) is saved in the Trial metadata and needs to match
upon restoration. This does not affect the typical path, since `Tuner.restore`
expects the exact same trainable (which will have the same name).
Args:
trainable: The trainable to validate.
required_trainable_name: If provided, the trainable's generated
name must match this value; used on restoration to detect a
trainable swap.
Raises:
ValueError: if the trainable name does not match or if the trainable
is not serializable.
"""
try:
pickle.dumps(trainable)
except TypeError as e:
sio = io.StringIO()
inspect_serializability(trainable, print_file=sio)
msg = (
"The provided trainable is not serializable, which is a requirement "
"since the trainable is serialized and deserialized when transferred "
"to remote workers. See below for a trace of the non-serializable "
"objects that were found in your trainable:\n"
f"{sio.getvalue()}"
)
raise TypeError(msg) from e
if not required_trainable_name:
return
trainable_name = Experiment.get_trainable_name(trainable)
if trainable_name != required_trainable_name:
raise ValueError(
"Invalid `trainable` input to `Tuner.restore()`. To fix this error, "
"pass in the same trainable that was used to initialize the Tuner. "
"Got a trainable with identifier "
f"'{trainable_name}' but expected '{required_trainable_name}'."
)
def _set_trainable_on_restore(
self, trainable: TrainableType, old_trainable_name: Optional[str]
):
from ray.train.base_trainer import BaseTrainer
self.trainable = trainable
assert self.converted_trainable
self._validate_trainable(
trainable=self.converted_trainable,
required_trainable_name=old_trainable_name,
)
if isinstance(self.trainable, BaseTrainer):
# Log a warning in case the user tries to modify the
# `RunConfig` from the Trainer
trainer: BaseTrainer = self.trainable
# Only log if the Trainer has a non-default RunConfig
if trainer.run_config != RunConfig():
logger.warning(
"The Tune experiment will restore using the original run's "
"`RunConfig`. If you made any changes to the `RunConfig` "
"within the Trainer you passed into `Tuner.restore`, "
"they will be ignored in the resumed run."
)
trainer.run_config = self._run_config
def _validate_param_space_on_restore(
self,
new_param_space: Dict[str, Any],
flattened_param_space_keys: Optional[List[str]],
) -> None:
"""Determines whether the (optionally) re-specified `param_space` is valid.
This method performs very loose validation on the new param_space to
prevent users from trying to specify new hyperparameters to tune over.
Args:
new_param_space: The newly provided search space to validate.
flattened_param_space_keys: Sorted flat keys of the original
``param_space``. ``None`` skips validation for backwards
compatibility.
Raises:
ValueError: if not all keys match the original param_space.
"""
if flattened_param_space_keys is None:
# Backwards compatibility: skip validation
return
keys = sorted(flatten_dict(new_param_space).keys())
if keys != flattened_param_space_keys:
raise ValueError(
"Invalid `param_space` input to `Tuner.restore()`. To fix this error, "
"pass in the same `param_space` that was used to initialize the Tuner. "
"Only re-specify the `param_space` to refresh Ray object references "
"that no longer exist due to restoring from a new Ray cluster session. "
"It should not be used to introduce new hyperparameters to tune."
f"\n\nGot: {keys}\nExpected: {flattened_param_space_keys}"
)
def _set_param_space_on_restore(
self,
param_space: Optional[Dict[str, Any]],
flattened_param_space_keys: Optional[List[str]],
):
self.param_space = param_space
if self.param_space is not None:
# param_space = None -> use the original param_space
self._validate_param_space_on_restore(
new_param_space=self.param_space,
flattened_param_space_keys=flattened_param_space_keys,
)
def _load_tuner_state(
self, tuner_state: Dict[str, Any]
) -> Tuple[Optional[str], Optional[List[str]]]:
"""Loads Tuner state from the previously saved `tuner.pkl`.
Args:
tuner_state: Deserialized contents of the `tuner.pkl` saved during
the original Tuner initialization.
Returns:
tuple: of `(old_trainable_name, flattened_param_space_keys)` used for
validating the re-specified `trainable` and `param_space`.
"""
# NOTE: These are magic keys used for validating restore args.
old_trainable_name = tuner_state.pop("__trainable_name", None)
flattened_param_space_keys = tuner_state.pop(
"__flattened_param_space_keys", None
)
self.__setstate__(tuner_state)
return old_trainable_name, flattened_param_space_keys
def _restore_from_path_or_uri(
self,
path_or_uri: str,
trainable: TrainableTypeOrTrainer,
overwrite_param_space: Optional[Dict[str, Any]],
resume_config: ResumeConfig,
storage_filesystem: Optional[pyarrow.fs.FileSystem],
):
fs, fs_path = get_fs_and_path(path_or_uri, storage_filesystem)
with fs.open_input_file(Path(fs_path, _TUNER_PKL).as_posix()) as f:
tuner_state = pickle.loads(f.readall())
old_trainable_name, flattened_param_space_keys = self._load_tuner_state(
tuner_state
)
# Perform validation and set the re-specified `trainable` and `param_space`
self._set_trainable_on_restore(
trainable=trainable, old_trainable_name=old_trainable_name
)
self._set_param_space_on_restore(
param_space=overwrite_param_space,
flattened_param_space_keys=flattened_param_space_keys,
)
# Update RunConfig to reflect changes in the experiment directory
path_or_uri_obj = URI(path_or_uri)
# Infer the `storage_path` and run `name` of the restored run using the
# experiment directory.
# Ex: ~/ray_results/exp_name -> ~/ray_results, exp_name
# Ex: s3://bucket/exp_name -> s3://bucket, exp_name
self._run_config.name = path_or_uri_obj.name
self._run_config.storage_path = str(path_or_uri_obj.parent)
# Update the storage_filesystem with the one passed in on restoration, if any.
self._run_config.storage_filesystem = storage_filesystem
# Load the experiment results at the point where it left off.
try:
self._experiment_analysis = ExperimentAnalysis(
experiment_checkpoint_path=path_or_uri,
default_metric=self._tune_config.metric,
default_mode=self._tune_config.mode,
storage_filesystem=storage_filesystem,
)
except Exception:
self._experiment_analysis = None
self._resume_config = resume_config
self._is_restored = True
def _choose_run_config(
self,
tuner_run_config: Optional[RunConfig],
trainer: "BaseTrainer",
param_space: Optional[Dict[str, Any]],
) -> RunConfig:
"""Chooses which `RunConfig` to use when multiple can be passed in
through a Trainer or the Tuner itself.
Args:
tuner_run_config: The run config passed into the Tuner constructor.
trainer: The Trainer instance to use with Tune, which may have
a RunConfig specified by the user.
param_space: The param space passed to the Tuner.
Returns:
The resolved ``RunConfig`` to use for the Tune experiment.
Raises:
ValueError: if the `run_config` is specified as a hyperparameter.
"""
if param_space and "run_config" in param_space:
raise ValueError(
"`RunConfig` cannot be tuned as part of the `param_space`! "
"Move the run config to be a parameter of the `Tuner`: "
"Tuner(..., run_config=RunConfig(...))"
)
# Both Tuner RunConfig + Trainer RunConfig --> prefer Tuner RunConfig
if tuner_run_config and trainer.run_config != ray.train.RunConfig():
logger.info(
"A `RunConfig` was passed to both the `Tuner` and the "
f"`{trainer.__class__.__name__}`. The run config passed to "
"the `Tuner` is the one that will be used."
)
return tuner_run_config
# No Tuner RunConfig -> pass the Trainer config through
# This returns either a user-specified config, or the default RunConfig
# if nothing was provided to both the Trainer or Tuner.
if not tuner_run_config:
return trainer.run_config
# Tuner RunConfig + No Trainer RunConfig --> Use the Tuner config
return tuner_run_config
def _process_scaling_config(self) -> None:
"""Converts ``self._param_space["scaling_config"]`` to a dict.
The dict is converted back to a dataclass by the Trainer, after the
Tune search specification is resolved.
"""
# TODO: introduce `ray.tune.sample.TuneableDataclass` and allow Tune to
# natively resolve specs with dataclasses.
scaling_config = self._param_space.get("scaling_config")
if not isinstance(scaling_config, ray.train.ScalingConfig):
return
self._param_space["scaling_config"] = scaling_config.__dict__.copy()
@property
def trainable(self) -> TrainableTypeOrTrainer:
return self._trainable
@property
def converted_trainable(self) -> TrainableType:
return self._converted_trainable
@trainable.setter
def trainable(self, trainable: TrainableTypeOrTrainer):
self._trainable = trainable
self._converted_trainable = self._convert_trainable(trainable)
@property
def param_space(self) -> Optional[Dict[str, Any]]:
return self._param_space
@param_space.setter
def param_space(self, param_space: Optional[Dict[str, Any]]):
# Handle any configs that adhere to the `to_dict` interface.
# Ex: AlgorithmConfig from RLlib
if isinstance(param_space, _Config):
param_space = param_space.to_dict()
if not isinstance(param_space, dict) and param_space is not None:
raise ValueError(
"The `param_space` passed to the `Tuner` must be a dict. "
f"Got '{type(param_space)}' instead."
)
self._param_space = param_space
if param_space:
self._process_scaling_config()
def _convert_trainable(self, trainable: TrainableTypeOrTrainer) -> TrainableType:
"""Converts a Trainer to a Tune trainable and saves the converted
trainable. If not using a Trainer, this leaves the trainable as is."""
from ray.train.trainer import BaseTrainer
return (
trainable.as_trainable()
if isinstance(trainable, BaseTrainer)
else trainable
)
def fit(self) -> ResultGrid:
trainable = self.converted_trainable
param_space = copy.deepcopy(self.param_space)
if not self._is_restored:
analysis = self._fit_internal(trainable, param_space)
else:
analysis = self._fit_resume(trainable, param_space)
self._experiment_analysis = analysis
return ResultGrid(self._experiment_analysis)
def get_results(self) -> ResultGrid:
if not self._experiment_analysis:
raise RuntimeError(
"Can't return results as experiment has not been run, yet. "
"Call `Tuner.fit()` to run the experiment first."
)
return ResultGrid(self._experiment_analysis)
def _get_tune_run_arguments(self, trainable: TrainableType) -> Dict[str, Any]:
"""Get tune.run arguments common for both new and resumed runs."""
# Avoid overwriting the originally configured checkpoint config.
checkpoint_config = copy.deepcopy(self._run_config.checkpoint_config)
if checkpoint_config.checkpoint_frequency:
# Function trainables (and thus most of our trainers) usually don't handle
# this argument.
handle_checkpoint_freq = getattr(
trainable, "_handles_checkpoint_freq", None
)
if handle_checkpoint_freq is False:
# If we specifically know this trainable doesn't support the
# argument, raise an error
raise ValueError(
"You passed `checkpoint_frequency="
f"{checkpoint_config.checkpoint_frequency}` to your "
"CheckpointConfig, but this trainer does not support "
"this argument. If you passed in a Trainer that takes in a "
"custom training loop, you will need to "
"report a checkpoint every `checkpoint_frequency` iterations "
"within your training loop using "
"`ray.tune.report(metrics=..., checkpoint=...)` "
"to get this behavior."
)
elif handle_checkpoint_freq is True:
# If we specifically support it, it's handled in the training loop,
# so we disable tune's bookkeeping.
checkpoint_config.checkpoint_frequency = 0
# Otherwise, the trainable is not a Trainer and we just keep the
# user-supplied value.
# Function trainables will raise a runtime error later if set > 0
if checkpoint_config.checkpoint_at_end is not None:
# Again, function trainables usually don't handle this argument.
handle_cp_at_end = getattr(trainable, "_handles_checkpoint_at_end", None)
if handle_cp_at_end is False:
# If we specifically know we don't support it, raise an error.
raise ValueError(
"You passed `checkpoint_at_end="
f"{checkpoint_config.checkpoint_at_end}` "
"to your CheckpointConfig, but this trainer does not support "
"this argument. If you passed in a Trainer that takes in a "
"custom training loop, you should include one last call to "
"`ray.tune.report(metrics=..., checkpoint=...)` "
"at the end of your training loop to get this behavior."
)
elif handle_cp_at_end is True:
# If we specifically support it, it's handled in the training loop,
# so we disable tune's internal bookkeeping.
checkpoint_config.checkpoint_at_end = False
# If this is a user-defined trainable, just keep the value
# Function trainables will raise a runtime error later if set to True
else:
# Set default to False for function trainables and True for everything else
if is_function_trainable(trainable):
checkpoint_config.checkpoint_at_end = False
else:
checkpoint_config.checkpoint_at_end = True
return dict(
storage_path=self._run_config.storage_path,
storage_filesystem=self._run_config.storage_filesystem,
name=self._run_config.name,
mode=self._tune_config.mode,
metric=self._tune_config.metric,
callbacks=self._run_config.callbacks,
sync_config=self._run_config.sync_config,
stop=self._run_config.stop,
max_failures=self._run_config.failure_config.max_failures,
checkpoint_config=checkpoint_config,
raise_on_failed_trial=False,
fail_fast=(self._run_config.failure_config.fail_fast),
progress_reporter=self._run_config.progress_reporter,
verbose=self._run_config.verbose,
reuse_actors=self._tune_config.reuse_actors,
max_concurrent_trials=self._tune_config.max_concurrent_trials,
time_budget_s=self._tune_config.time_budget_s,
trial_name_creator=self._tune_config.trial_name_creator,
trial_dirname_creator=self._tune_config.trial_dirname_creator,
_entrypoint=self._entrypoint,
# Deprecated
chdir_to_trial_dir=self._tune_config.chdir_to_trial_dir,
)
def _fit_internal(
self, trainable: TrainableType, param_space: Optional[Dict[str, Any]]
) -> ExperimentAnalysis:
"""Fitting for a fresh Tuner."""
args = {
**self._get_tune_run_arguments(trainable),
**dict(
run_or_experiment=trainable,
config=param_space,
num_samples=self._tune_config.num_samples,
search_alg=self._tune_config.search_alg,
scheduler=self._tune_config.scheduler,
log_to_file=self._run_config.log_to_file,
),
**self._tuner_kwargs,
}
analysis = run(
**args,
)
self.clear_remote_string_queue()
return analysis
def _fit_resume(
self, trainable: TrainableType, param_space: Optional[Dict[str, Any]]
) -> ExperimentAnalysis:
"""Fitting for a restored Tuner."""
assert self._resume_config
args = {
**self._get_tune_run_arguments(trainable),
**dict(
run_or_experiment=trainable,
config=param_space,
resume_config=self._resume_config,
search_alg=self._tune_config.search_alg,
scheduler=self._tune_config.scheduler,
),
**self._tuner_kwargs,
}
analysis = run(**args)
self.clear_remote_string_queue()
return analysis
def __getstate__(self):
state = self.__dict__.copy()
state["_tuner_kwargs"] = state["_tuner_kwargs"].copy()
state["_tuner_kwargs"].pop("_remote_string_queue", None)
state.pop(_TRAINABLE_KEY, None)
trainable = state.pop(_CONVERTED_TRAINABLE_KEY, None)
param_space = state.pop(_PARAM_SPACE_KEY, None)
state.pop(_EXPERIMENT_ANALYSIS_KEY, None)
state["__trainable_name"] = (
Experiment.get_trainable_name(trainable) if trainable else None
)
state["__flattened_param_space_keys"] = (
sorted(flatten_dict(param_space).keys())
if param_space is not None
else None
)
return state
def __setstate__(self, state):
# Make sure the magic metadata gets removed first.
state.pop("__flattened_param_space_keys", None)
state.pop("__trainable_name", None)
self.__dict__.update(state)