chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<!-- Loaded on-demand when Claude works on Ray Train files. -->
|
||||
<!-- Keep under 50 lines. Multi-step procedures → skills. Code style → rules/. -->
|
||||
|
||||
# Ray Train
|
||||
|
||||
## Key Modules
|
||||
<!-- Entry points, important abstractions, non-obvious dependencies -->
|
||||
|
||||
## Gotchas
|
||||
<!-- Non-obvious behaviors, common mistakes, things that break silently -->
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- Add Ray Train team-specific rules here as .md files. -->
|
||||
<!-- Rules with paths: frontmatter only load when matching files are edited. -->
|
||||
<!-- Example:
|
||||
---
|
||||
paths:
|
||||
- "python/ray/train/**/*.py"
|
||||
---
|
||||
- Use the base trainer interface for new training backends
|
||||
-->
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
# Try import ray[train] core requirements (defined in setup.py)
|
||||
# isort: off
|
||||
try:
|
||||
import fsspec # noqa: F401
|
||||
import pandas # noqa: F401
|
||||
import pyarrow # noqa: F401
|
||||
import requests # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Can't import ray.train as some dependencies are missing. "
|
||||
'Run `pip install "ray[train]"` to fix.'
|
||||
) from exc
|
||||
# isort: on
|
||||
|
||||
|
||||
from ray.air.config import CheckpointConfig, FailureConfig, RunConfig, ScalingConfig
|
||||
from ray.air.result import Result
|
||||
|
||||
# Import this first so it can be used in other modules
|
||||
from ray.train._checkpoint import Checkpoint
|
||||
from ray.train._internal.data_config import DataConfig
|
||||
from ray.train._internal.session import get_checkpoint, get_dataset_shard, report
|
||||
from ray.train._internal.syncer import SyncConfig
|
||||
from ray.train.backend import BackendConfig
|
||||
from ray.train.base_trainer import TrainingFailedError
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
from ray.train.context import TrainContext, get_context
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
try:
|
||||
import pydantic # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError) as exc:
|
||||
raise ImportError(
|
||||
"`ray.train.v2` requires the pydantic package, which is missing. "
|
||||
"Run the following command to fix this: `pip install pydantic`"
|
||||
) from exc
|
||||
from ray.train.v2.api.callback import UserCallback # noqa: F811
|
||||
from ray.train.v2.api.config import ( # noqa: F811
|
||||
CheckpointConfig,
|
||||
FailureConfig,
|
||||
LoggingConfig,
|
||||
RunConfig,
|
||||
ScalingConfig,
|
||||
)
|
||||
from ray.train.v2.api.context import TrainContext # noqa: F811
|
||||
from ray.train.v2.api.exceptions import ( # noqa: F811
|
||||
ControllerError,
|
||||
TrainingFailedError,
|
||||
WorkerGroupError,
|
||||
)
|
||||
from ray.train.v2.api.report_config import ( # noqa: F811
|
||||
CheckpointConsistencyMode,
|
||||
CheckpointUploadMode,
|
||||
)
|
||||
from ray.train.v2.api.reported_checkpoint import ( # noqa: F811
|
||||
ReportedCheckpoint,
|
||||
ReportedCheckpointStatus,
|
||||
)
|
||||
from ray.train.v2.api.result import Result # noqa: F811
|
||||
from ray.train.v2.api.train_fn_utils import ( # noqa: F811
|
||||
get_all_reported_checkpoints,
|
||||
get_checkpoint,
|
||||
get_context,
|
||||
get_dataset_shard,
|
||||
report,
|
||||
)
|
||||
from ray.train.v2.api.validation_config import ( # noqa: F811
|
||||
ValidationConfig,
|
||||
ValidationFn,
|
||||
ValidationTaskConfig,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_checkpoint",
|
||||
"get_context",
|
||||
"get_dataset_shard",
|
||||
"report",
|
||||
"BackendConfig",
|
||||
"Checkpoint",
|
||||
"CheckpointConfig",
|
||||
"DataConfig",
|
||||
"FailureConfig",
|
||||
"Result",
|
||||
"RunConfig",
|
||||
"ScalingConfig",
|
||||
"SyncConfig",
|
||||
"TrainContext",
|
||||
"TrainingFailedError",
|
||||
"TRAIN_DATASET_KEY",
|
||||
]
|
||||
|
||||
get_checkpoint.__module__ = "ray.train"
|
||||
get_context.__module__ = "ray.train"
|
||||
get_dataset_shard.__module__ = "ray.train"
|
||||
report.__module__ = "ray.train"
|
||||
BackendConfig.__module__ = "ray.train"
|
||||
Checkpoint.__module__ = "ray.train"
|
||||
CheckpointConfig.__module__ = "ray.train"
|
||||
DataConfig.__module__ = "ray.train"
|
||||
FailureConfig.__module__ = "ray.train"
|
||||
Result.__module__ = "ray.train"
|
||||
RunConfig.__module__ = "ray.train"
|
||||
ScalingConfig.__module__ = "ray.train"
|
||||
SyncConfig.__module__ = "ray.train"
|
||||
TrainContext.__module__ = "ray.train"
|
||||
TrainingFailedError.__module__ = "ray.train"
|
||||
|
||||
# TODO: consider implementing these in v1 and raising ImportError instead.
|
||||
if is_v2_enabled():
|
||||
__all__.extend(
|
||||
[
|
||||
"CheckpointUploadMode",
|
||||
"CheckpointConsistencyMode",
|
||||
"ControllerError",
|
||||
"LoggingConfig",
|
||||
"ReportedCheckpoint",
|
||||
"ReportedCheckpointStatus",
|
||||
"UserCallback",
|
||||
"WorkerGroupError",
|
||||
"ValidationConfig",
|
||||
"ValidationFn",
|
||||
"ValidationTaskConfig",
|
||||
"get_all_reported_checkpoints",
|
||||
]
|
||||
)
|
||||
|
||||
CheckpointUploadMode.__module__ = "ray.train"
|
||||
CheckpointConsistencyMode.__module__ = "ray.train"
|
||||
ControllerError.__module__ = "ray.train"
|
||||
LoggingConfig.__module__ = "ray.train"
|
||||
ReportedCheckpoint.__module__ = "ray.train"
|
||||
ReportedCheckpointStatus.__module__ = "ray.train"
|
||||
UserCallback.__module__ = "ray.train"
|
||||
WorkerGroupError.__module__ = "ray.train"
|
||||
ValidationConfig.__module__ = "ray.train"
|
||||
ValidationFn.__module__ = "ray.train"
|
||||
ValidationTaskConfig.__module__ = "ray.train"
|
||||
get_all_reported_checkpoints.__module__ = "ray.train"
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,434 @@
|
||||
import contextlib
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import traceback
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
||||
|
||||
import pyarrow.fs
|
||||
|
||||
from ray.air._internal.filelock import TempFileLock
|
||||
from ray.train._internal.storage import _download_from_fs_path, _exists_at_fs_path
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The filename of the file that stores user metadata set on the checkpoint.
|
||||
_METADATA_FILE_NAME = ".metadata.json"
|
||||
|
||||
# The prefix of the temp checkpoint directory that `to_directory` downloads to
|
||||
# on the local filesystem.
|
||||
_CHECKPOINT_TEMP_DIR_PREFIX = "checkpoint_tmp_"
|
||||
|
||||
|
||||
class _CheckpointMetaClass(type):
|
||||
def __getattr__(self, item):
|
||||
try:
|
||||
return super().__getattribute__(item)
|
||||
except AttributeError as exc:
|
||||
if item in {
|
||||
"from_dict",
|
||||
"to_dict",
|
||||
"from_bytes",
|
||||
"to_bytes",
|
||||
"get_internal_representation",
|
||||
}:
|
||||
raise _get_migration_error(item) from exc
|
||||
elif item in {
|
||||
"from_uri",
|
||||
"to_uri",
|
||||
"uri",
|
||||
}:
|
||||
raise _get_uri_error(item) from exc
|
||||
elif item in {"get_preprocessor", "set_preprocessor"}:
|
||||
raise _get_preprocessor_error(item) from exc
|
||||
|
||||
raise exc
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class Checkpoint(metaclass=_CheckpointMetaClass):
|
||||
"""A reference to data persisted as a directory in local or remote storage.
|
||||
|
||||
Access the checkpoint contents locally using ``checkpoint.to_directory()``
|
||||
or ``checkpoint.as_directory``.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
path: A path on the filesystem containing the checkpoint contents.
|
||||
filesystem: PyArrow FileSystem that can be used to access data at the `path`.
|
||||
|
||||
See Also
|
||||
--------
|
||||
ray.train.report : Report a checkpoint during training (with Ray Train/Tune).
|
||||
ray.train.get_checkpoint : Get the latest checkpoint during training
|
||||
(for restoration).
|
||||
|
||||
:ref:`train-checkpointing`
|
||||
:ref:`persistent-storage-guide`
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Creating a checkpoint using ``Checkpoint.from_directory``:
|
||||
|
||||
>>> from ray.train import Checkpoint
|
||||
>>> checkpoint = Checkpoint.from_directory("/tmp/example_checkpoint_dir")
|
||||
>>> checkpoint.filesystem # doctest: +ELLIPSIS
|
||||
<pyarrow._fs.LocalFileSystem object...
|
||||
>>> checkpoint.path
|
||||
'/tmp/example_checkpoint_dir'
|
||||
|
||||
Creating a checkpoint from a remote URI:
|
||||
|
||||
>>> checkpoint = Checkpoint("s3://bucket/path/to/checkpoint")
|
||||
>>> checkpoint.filesystem # doctest: +ELLIPSIS
|
||||
<pyarrow._s3fs.S3FileSystem object...
|
||||
>>> checkpoint.path
|
||||
'bucket/path/to/checkpoint'
|
||||
|
||||
Creating a checkpoint with a custom filesystem:
|
||||
|
||||
>>> checkpoint = Checkpoint(
|
||||
... path="bucket/path/to/checkpoint",
|
||||
... filesystem=pyarrow.fs.S3FileSystem(),
|
||||
... )
|
||||
>>> checkpoint.filesystem # doctest: +ELLIPSIS
|
||||
<pyarrow._s3fs.S3FileSystem object...
|
||||
>>> checkpoint.path
|
||||
'bucket/path/to/checkpoint'
|
||||
|
||||
Accessing a checkpoint's contents:
|
||||
|
||||
>>> import os # doctest: +SKIP
|
||||
>>> with checkpoint.as_directory() as local_checkpoint_dir: # doctest: +SKIP
|
||||
... print(os.listdir(local_checkpoint_dir)) # doctest: +SKIP
|
||||
['model.pt', 'optimizer.pt', 'misc.pt']
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[str, os.PathLike],
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
):
|
||||
"""Construct a Checkpoint.
|
||||
|
||||
Args:
|
||||
path: A local path or remote URI containing the checkpoint data.
|
||||
If a filesystem is provided, then this path must NOT be a URI.
|
||||
It should be a path on the filesystem with the prefix already stripped.
|
||||
filesystem: PyArrow FileSystem to use to access data at the path.
|
||||
If not specified, this is inferred from the URI scheme.
|
||||
"""
|
||||
self.path = str(path)
|
||||
self.filesystem = filesystem
|
||||
|
||||
if path and not filesystem:
|
||||
self.filesystem, self.path = pyarrow.fs.FileSystem.from_uri(path)
|
||||
|
||||
# This random UUID is used to create a temporary directory name on the
|
||||
# local filesystem, which will be used for downloading checkpoint data.
|
||||
# This ensures that if multiple processes download the same checkpoint object
|
||||
# only one process performs the actual download while the others wait.
|
||||
# This prevents duplicated download efforts and data.
|
||||
# NOTE: Calling `to_directory` from multiple `Checkpoint` objects
|
||||
# that point to the same (fs, path) will still download the data multiple times.
|
||||
# This only ensures a canonical temp directory name for a single `Checkpoint`.
|
||||
self._uuid = uuid.uuid4()
|
||||
|
||||
def __repr__(self):
|
||||
return f"Checkpoint(filesystem={self.filesystem.type_name}, path={self.path})"
|
||||
|
||||
def get_metadata(self) -> Dict[str, Any]:
|
||||
"""Return the metadata dict stored with the checkpoint.
|
||||
|
||||
If no metadata is stored, an empty dict is returned.
|
||||
"""
|
||||
metadata_path = Path(self.path, _METADATA_FILE_NAME).as_posix()
|
||||
if not _exists_at_fs_path(self.filesystem, metadata_path):
|
||||
return {}
|
||||
|
||||
with self.filesystem.open_input_file(metadata_path) as f:
|
||||
return json.loads(f.readall().decode("utf-8"))
|
||||
|
||||
def set_metadata(self, metadata: Dict[str, Any]) -> None:
|
||||
"""Set the metadata stored with this checkpoint.
|
||||
|
||||
This will overwrite any existing metadata stored with this checkpoint.
|
||||
"""
|
||||
metadata_path = Path(self.path, _METADATA_FILE_NAME).as_posix()
|
||||
with self.filesystem.open_output_stream(metadata_path) as f:
|
||||
f.write(json.dumps(metadata).encode("utf-8"))
|
||||
|
||||
def update_metadata(self, metadata: Dict[str, Any]) -> None:
|
||||
"""Update the metadata stored with this checkpoint.
|
||||
|
||||
This will update any existing metadata stored with this checkpoint.
|
||||
"""
|
||||
existing_metadata = self.get_metadata()
|
||||
existing_metadata.update(metadata)
|
||||
self.set_metadata(existing_metadata)
|
||||
|
||||
@classmethod
|
||||
def from_directory(cls, path: Union[str, os.PathLike]) -> "Checkpoint":
|
||||
"""Create checkpoint object from a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory containing checkpoint data.
|
||||
|
||||
Returns:
|
||||
A ray.train.Checkpoint object.
|
||||
"""
|
||||
return cls(path, filesystem=pyarrow.fs.LocalFileSystem())
|
||||
|
||||
def to_directory(self, path: Optional[Union[str, os.PathLike]] = None) -> str:
|
||||
"""Write checkpoint data to a local directory.
|
||||
|
||||
*If multiple processes on the same node call this method simultaneously,*
|
||||
only a single process will perform the download, while the others
|
||||
wait for the download to finish. Once the download finishes, all processes
|
||||
receive the same local directory to read from.
|
||||
|
||||
Args:
|
||||
path: Target directory to download data to. If not specified,
|
||||
this method will use a temporary directory.
|
||||
|
||||
Returns:
|
||||
str: Directory containing checkpoint data.
|
||||
"""
|
||||
user_provided_path = path is not None
|
||||
local_path = (
|
||||
path if user_provided_path else self._get_temporary_checkpoint_dir()
|
||||
)
|
||||
local_path = os.path.normpath(os.path.expanduser(str(local_path)))
|
||||
os.makedirs(local_path, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Timeout 0 means there will be only one attempt to acquire
|
||||
# the file lock. If it cannot be acquired, throw a TimeoutError
|
||||
with TempFileLock(local_path, timeout=0):
|
||||
_download_from_fs_path(
|
||||
fs=self.filesystem, fs_path=self.path, local_path=local_path
|
||||
)
|
||||
except TimeoutError:
|
||||
# if the directory is already locked, then wait but do not do anything.
|
||||
with TempFileLock(local_path, timeout=-1):
|
||||
pass
|
||||
if not os.path.exists(local_path):
|
||||
raise RuntimeError(
|
||||
f"Checkpoint directory {local_path} does not exist, "
|
||||
"even though it should have been created by "
|
||||
"another process. Please raise an issue on GitHub: "
|
||||
"https://github.com/ray-project/ray/issues"
|
||||
)
|
||||
|
||||
return local_path
|
||||
|
||||
@contextlib.contextmanager
|
||||
def as_directory(self) -> Iterator[str]:
|
||||
"""Returns checkpoint contents in a local directory as a context.
|
||||
|
||||
This function makes checkpoint data available as a directory while avoiding
|
||||
unnecessary copies and left-over temporary data.
|
||||
|
||||
*If the checkpoint points to a local directory*, this method just returns the
|
||||
local directory path without making a copy, and nothing will be cleaned up
|
||||
after exiting the context.
|
||||
|
||||
*If the checkpoint points to a remote directory*, this method will download the
|
||||
checkpoint to a local temporary directory and return the path
|
||||
to the temporary directory.
|
||||
|
||||
*If multiple processes on the same node call this method simultaneously,*
|
||||
only a single process will perform the download, while the others
|
||||
wait for the download to finish. Once the download finishes, all processes
|
||||
receive the same local (temporary) directory to read from.
|
||||
|
||||
Once all processes have finished working with the checkpoint,
|
||||
the temporary directory is cleaned up.
|
||||
|
||||
Users should treat the returned checkpoint directory as read-only and avoid
|
||||
changing any data within it, as it may be deleted when exiting the context.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
:hide:
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from ray.train import Checkpoint
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
(Path(temp_dir) / "example.txt").write_text("example checkpoint data")
|
||||
checkpoint = Checkpoint.from_directory(temp_dir)
|
||||
|
||||
.. testcode::
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
# Do some read-only processing of files within checkpoint_dir
|
||||
pass
|
||||
|
||||
# At this point, if a temporary directory was created, it will have
|
||||
# been deleted.
|
||||
|
||||
Yields:
|
||||
str: The local path to the checkpoint directory.
|
||||
"""
|
||||
if isinstance(self.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
yield self.path
|
||||
else:
|
||||
del_lock_path = _get_del_lock_path(self._get_temporary_checkpoint_dir())
|
||||
open(del_lock_path, "a").close()
|
||||
|
||||
temp_dir = self.to_directory()
|
||||
try:
|
||||
yield temp_dir
|
||||
finally:
|
||||
# Always cleanup the del lock after we're done with the directory.
|
||||
# This avoids leaving a lock file behind in the case of an exception
|
||||
# in the user code.
|
||||
try:
|
||||
os.remove(del_lock_path)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Could not remove {del_lock_path} deletion file lock. "
|
||||
f"Traceback:\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
# If there are no more lock files, that means there are no more
|
||||
# readers of this directory, and we can safely delete it.
|
||||
# In the edge case (process crash before del lock file is removed),
|
||||
# we do not remove the directory at all.
|
||||
# Since it's in /tmp, this is not that big of a deal.
|
||||
# check if any lock files are remaining
|
||||
remaining_locks = _list_existing_del_locks(temp_dir)
|
||||
if not remaining_locks:
|
||||
try:
|
||||
# Timeout 0 means there will be only one attempt to acquire
|
||||
# the file lock. If it cannot be acquired, a TimeoutError
|
||||
# will be thrown.
|
||||
with TempFileLock(temp_dir, timeout=0):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
def _get_temporary_checkpoint_dir(self) -> str:
|
||||
"""Return the name for the temporary checkpoint dir that this checkpoint
|
||||
will get downloaded to, if accessing via `to_directory` or `as_directory`.
|
||||
"""
|
||||
tmp_dir_path = tempfile.gettempdir()
|
||||
checkpoint_dir_name = _CHECKPOINT_TEMP_DIR_PREFIX + self._uuid.hex
|
||||
if platform.system() == "Windows":
|
||||
# Max path on Windows is 260 chars, -1 for joining \
|
||||
# Also leave a little for the del lock
|
||||
del_lock_name = _get_del_lock_path("")
|
||||
checkpoint_dir_name = (
|
||||
_CHECKPOINT_TEMP_DIR_PREFIX
|
||||
+ self._uuid.hex[
|
||||
-259
|
||||
+ len(_CHECKPOINT_TEMP_DIR_PREFIX)
|
||||
+ len(tmp_dir_path)
|
||||
+ len(del_lock_name) :
|
||||
]
|
||||
)
|
||||
if not checkpoint_dir_name.startswith(_CHECKPOINT_TEMP_DIR_PREFIX):
|
||||
raise RuntimeError(
|
||||
"Couldn't create checkpoint directory due to length "
|
||||
"constraints. Try specifying a shorter checkpoint path."
|
||||
)
|
||||
return Path(tmp_dir_path, checkpoint_dir_name).as_posix()
|
||||
|
||||
def __fspath__(self):
|
||||
raise TypeError(
|
||||
"You cannot use `Checkpoint` objects directly as paths. "
|
||||
"Use `Checkpoint.to_directory()` or `Checkpoint.as_directory()` instead."
|
||||
)
|
||||
|
||||
|
||||
def _get_del_lock_path(path: str, suffix: str = None) -> str:
|
||||
"""Get the path to the deletion lock file for a file/directory at `path`.
|
||||
|
||||
Args:
|
||||
path: The path of the file or directory to generate a lock path for.
|
||||
suffix: Suffix appended after ``.del_lock_``. Defaults to the current
|
||||
process ID.
|
||||
|
||||
Returns:
|
||||
The deletion lock file path.
|
||||
|
||||
Example:
|
||||
|
||||
>>> _get_del_lock_path("/tmp/checkpoint_tmp") # doctest: +ELLIPSIS
|
||||
'/tmp/checkpoint_tmp.del_lock_...
|
||||
>>> _get_del_lock_path("/tmp/checkpoint_tmp/") # doctest: +ELLIPSIS
|
||||
'/tmp/checkpoint_tmp.del_lock_...
|
||||
>>> _get_del_lock_path("/tmp/checkpoint_tmp.txt") # doctest: +ELLIPSIS
|
||||
'/tmp/checkpoint_tmp.txt.del_lock_...
|
||||
|
||||
"""
|
||||
suffix = suffix if suffix is not None else str(os.getpid())
|
||||
return f"{path.rstrip('/')}.del_lock_{suffix}"
|
||||
|
||||
|
||||
def _list_existing_del_locks(path: str) -> List[str]:
|
||||
"""List all the deletion lock files for a file/directory at `path`.
|
||||
|
||||
For example, if 2 checkpoints are being read via `as_directory`,
|
||||
then this should return a list of 2 deletion lock files.
|
||||
"""
|
||||
return list(glob.glob(f"{_get_del_lock_path(path, suffix='*')}"))
|
||||
|
||||
|
||||
def _get_migration_error(name: str):
|
||||
return AttributeError(
|
||||
f"The new `ray.train.Checkpoint` class does not support `{name}()`. "
|
||||
f"Instead, only directories are supported.\n\n"
|
||||
f"Example to store a dictionary in a checkpoint:\n\n"
|
||||
f"import os, tempfile\n"
|
||||
f"import ray.cloudpickle as pickle\n"
|
||||
f"from ray import train\n"
|
||||
f"from ray.train import Checkpoint\n\n"
|
||||
f"with tempfile.TemporaryDirectory() as checkpoint_dir:\n"
|
||||
f" with open(os.path.join(checkpoint_dir, 'data.pkl'), 'wb') as fp:\n"
|
||||
f" pickle.dump({{'data': 'value'}}, fp)\n\n"
|
||||
f" checkpoint = Checkpoint.from_directory(checkpoint_dir)\n"
|
||||
f" train.report(..., checkpoint=checkpoint)\n\n"
|
||||
f"Example to load a dictionary from a checkpoint:\n\n"
|
||||
f"if train.get_checkpoint():\n"
|
||||
f" with train.get_checkpoint().as_directory() as checkpoint_dir:\n"
|
||||
f" with open(os.path.join(checkpoint_dir, 'data.pkl'), 'rb') as fp:\n"
|
||||
f" data = pickle.load(fp)"
|
||||
)
|
||||
|
||||
|
||||
def _get_uri_error(name: str):
|
||||
return AttributeError(
|
||||
f"The new `ray.train.Checkpoint` class does not support `{name}()`. "
|
||||
f"To create a checkpoint from remote storage, create a `Checkpoint` using its "
|
||||
f"constructor instead of `from_directory`.\n"
|
||||
f'Example: `Checkpoint(path="s3://a/b/c")`.\n'
|
||||
f"Then, access the contents of the checkpoint with "
|
||||
f"`checkpoint.as_directory()` / `checkpoint.to_directory()`.\n"
|
||||
f"To upload data to remote storage, use e.g. `pyarrow.fs.FileSystem` "
|
||||
f"or your client of choice."
|
||||
)
|
||||
|
||||
|
||||
def _get_preprocessor_error(name: str):
|
||||
return AttributeError(
|
||||
f"The new `ray.train.Checkpoint` class does not support `{name}()`. "
|
||||
f"To include preprocessor information in checkpoints, "
|
||||
f"pass it as metadata in the <Framework>Trainer constructor.\n"
|
||||
f"Example: `TorchTrainer(..., metadata={{...}})`.\n"
|
||||
f"After training, access it in the checkpoint via `checkpoint.get_metadata()`. "
|
||||
f"See here: https://docs.ray.io/en/master/train/user-guides/"
|
||||
f"data-loading-preprocessing.html#preprocessing-structured-data"
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import abc
|
||||
|
||||
|
||||
class Accelerator(abc.ABC):
|
||||
"""A utility that contains methods to accelerate training."""
|
||||
@@ -0,0 +1,858 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray._private.accelerators.amd_gpu import HIP_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.accelerators.neuron import NEURON_RT_VISIBLE_CORES_ENV_VAR
|
||||
from ray._private.accelerators.npu import ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.accelerators.nvidia_gpu import CUDA_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray.exceptions import RayActorError
|
||||
from ray.train._checkpoint import Checkpoint
|
||||
from ray.train._internal.data_config import DataConfig
|
||||
from ray.train._internal.session import (
|
||||
TrialInfo,
|
||||
_TrainingResult,
|
||||
get_session,
|
||||
init_session,
|
||||
shutdown_session,
|
||||
)
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train._internal.utils import check_for_failure
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import BackendConfig
|
||||
from ray.train.constants import (
|
||||
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV,
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
|
||||
ENABLE_SHARE_HIP_VISIBLE_DEVICES_ENV,
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
|
||||
ENABLE_SHARE_NPU_RT_VISIBLE_DEVICES_ENV,
|
||||
RAY_TRAIN_ENABLE_STATE_TRACKING,
|
||||
TRAIN_ENABLE_WORKER_SPREAD_ENV,
|
||||
TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV,
|
||||
)
|
||||
from ray.util.placement_group import get_current_placement_group, remove_placement_group
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import Dataset
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrainBackendError(Exception):
|
||||
"""Errors with BackendExecutor that should not be exposed to user."""
|
||||
|
||||
|
||||
class TrainingWorkerError(Exception):
|
||||
"""Raised if a worker fails during training."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceConfig:
|
||||
"""
|
||||
Resource configuration for resource_ids to share between workers.
|
||||
|
||||
Args:
|
||||
resource_name: The name of the resource to configure
|
||||
(Example: "neuron_cores" or "gpu").
|
||||
resource_enable_sharing_env_var: The environment variable to
|
||||
check if the resource should be shared.
|
||||
share_resource_ids_env_var: The environment variable to configure for
|
||||
sharing the resources with other workers.
|
||||
"""
|
||||
|
||||
resource_name: str
|
||||
resource_enable_sharing_env_var: str
|
||||
share_resource_ids_env_var: str
|
||||
|
||||
|
||||
class BackendExecutor:
|
||||
"""Main execution class for training backends.
|
||||
|
||||
This class holds a worker group and is responsible for executing the
|
||||
training function on the workers, and collecting intermediate results
|
||||
from ``session.report()``.
|
||||
|
||||
Args:
|
||||
backend_config: The configurations for this
|
||||
specific backend.
|
||||
trial_info: Information about the current Tune trial, if running under Tune.
|
||||
num_workers: Number of workers to use for training.
|
||||
resources_per_worker: Dictionary specifying the resources that will be
|
||||
requested for each worker. Defaults to {"CPU": 1}.
|
||||
max_retries: Number of retries when Ray actors fail.
|
||||
Defaults to 3. Set to -1 for unlimited retries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend_config: BackendConfig,
|
||||
# TODO(xwjiang): Legacy Ray Train trainer clean up!
|
||||
trial_info: Optional[TrialInfo] = None,
|
||||
num_workers: int = 1,
|
||||
resources_per_worker: Optional[Dict[str, float]] = None,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
if resources_per_worker is None:
|
||||
self._resources_per_worker = {"CPU": 1}
|
||||
else:
|
||||
self._resources_per_worker = resources_per_worker.copy()
|
||||
|
||||
self._backend_config = backend_config
|
||||
self._backend = backend_config.backend_cls()
|
||||
self._num_workers = num_workers
|
||||
self._max_failures = max_retries
|
||||
if self._max_failures < 0:
|
||||
self._max_failures = float("inf")
|
||||
self._num_failures = 0
|
||||
self._last_failure = None
|
||||
self._initialization_hook = None
|
||||
self._placement_group = None
|
||||
|
||||
self._trial_info = trial_info
|
||||
|
||||
self.worker_group = InactiveWorkerGroup()
|
||||
self.dataset_shards = None
|
||||
|
||||
self._resource_configs = [
|
||||
ResourceConfig(
|
||||
ray_constants.NEURON_CORES,
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
|
||||
NEURON_RT_VISIBLE_CORES_ENV_VAR,
|
||||
),
|
||||
ResourceConfig(
|
||||
ray_constants.NPU,
|
||||
ENABLE_SHARE_NPU_RT_VISIBLE_DEVICES_ENV,
|
||||
ASCEND_RT_VISIBLE_DEVICES_ENV_VAR,
|
||||
),
|
||||
# For AMD GPUs, they are using HIP_VISIBLE_DEVICES env var.
|
||||
ResourceConfig(
|
||||
ray_constants.GPU,
|
||||
ENABLE_SHARE_HIP_VISIBLE_DEVICES_ENV,
|
||||
HIP_VISIBLE_DEVICES_ENV_VAR,
|
||||
),
|
||||
]
|
||||
|
||||
# Record the initialization time of BackendExecutor, which is
|
||||
# after trainer.fit() and before worker_group executes the training function.
|
||||
self._start_time_ms = int(time.time() * 1000)
|
||||
|
||||
self.state_tracking_enabled = env_integer(RAY_TRAIN_ENABLE_STATE_TRACKING, 0)
|
||||
|
||||
def start(
|
||||
self,
|
||||
initialization_hook: Optional[Callable[[], None]] = None,
|
||||
train_cls: Optional[Type] = None,
|
||||
train_cls_args: Optional[Tuple] = None,
|
||||
train_cls_kwargs: Optional[Dict] = None,
|
||||
):
|
||||
"""Starts the worker group."""
|
||||
self._create_placement_group()
|
||||
placement_group = self._placement_group or "default"
|
||||
self.worker_group = WorkerGroup(
|
||||
num_workers=self._num_workers,
|
||||
resources_per_worker=self._resources_per_worker,
|
||||
actor_cls=train_cls,
|
||||
actor_cls_args=train_cls_args,
|
||||
actor_cls_kwargs=train_cls_kwargs,
|
||||
placement_group=placement_group,
|
||||
)
|
||||
# Hack to avoid OOMs.
|
||||
# This is just a temporary solution for Train loading entire checkpoints
|
||||
# into memory by ensuring that the rank 0 worker is on the same node as
|
||||
# trainable, thus allowing for lazy checkpoint transfer to be used.
|
||||
# See https://github.com/ray-project/ray/issues/33073
|
||||
# for more context.
|
||||
# TODO remove passing in trial_driver_ip.
|
||||
|
||||
trial_driver_node_id = (
|
||||
self._trial_info.driver_node_id if self._trial_info else None
|
||||
)
|
||||
self.worker_group.sort_workers_by_node_id_and_gpu_id(trial_driver_node_id)
|
||||
|
||||
try:
|
||||
if initialization_hook:
|
||||
self._initialization_hook = initialization_hook
|
||||
self.worker_group.execute(initialization_hook)
|
||||
|
||||
# Always propagate the driver's DataContext to each worker in the group.
|
||||
from ray.data import DataContext
|
||||
|
||||
def _set_driver_dataset_context(ctx: DataContext):
|
||||
DataContext._set_current(ctx)
|
||||
|
||||
self.worker_group.execute(
|
||||
_set_driver_dataset_context,
|
||||
DataContext.get_current(),
|
||||
)
|
||||
|
||||
share_cuda_visible_devices_enabled = bool(
|
||||
env_integer(
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
|
||||
self._backend.share_cuda_visible_devices,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
self._resources_per_worker.get("GPU", 0) > 0
|
||||
and share_cuda_visible_devices_enabled
|
||||
):
|
||||
self._share_cuda_visible_devices()
|
||||
for resource_config in self._resource_configs:
|
||||
if self._is_share_resources_enabled(
|
||||
resource_config.resource_name,
|
||||
resource_config.resource_enable_sharing_env_var,
|
||||
):
|
||||
self._share_resource_ids(
|
||||
resource_config.resource_name,
|
||||
resource_config.share_resource_ids_env_var,
|
||||
)
|
||||
self._backend.on_start(self.worker_group, self._backend_config)
|
||||
except RayActorError as exc:
|
||||
logger.exception(str(exc))
|
||||
logger.warning(
|
||||
"Failure occurred during startup. Restarting all workers and "
|
||||
"attempting to startup again."
|
||||
)
|
||||
self._increment_failures()
|
||||
self._restart()
|
||||
|
||||
if self.state_tracking_enabled:
|
||||
from ray.train._internal.state import TrainRunStateManager
|
||||
from ray.train._internal.state.state_actor import get_state_actor
|
||||
|
||||
self.state_manager = TrainRunStateManager(state_actor=get_state_actor())
|
||||
|
||||
def _create_placement_group(self):
|
||||
"""Creates a placement group if it does not exist.
|
||||
|
||||
If a placement group is already detected (Tune) this will be a no-op.
|
||||
|
||||
By default the placement group will be created with PACK strategy.
|
||||
This is optimized for colocating GPUs on a minimal number of nodes.
|
||||
This behavior can be overridden to use the SPREAD strategy by defining
|
||||
``TRAIN_ENABLE_WORKER_SPREAD_ENV``
|
||||
|
||||
If a placement group is created it will be stored as
|
||||
self._placement_group.
|
||||
"""
|
||||
current_placement_group = get_current_placement_group()
|
||||
worker = ray._private.worker.global_worker
|
||||
should_capture_child_tasks_in_placement_group = (
|
||||
worker.should_capture_child_tasks_in_placement_group
|
||||
)
|
||||
should_create_placement_group = (
|
||||
current_placement_group is None
|
||||
or not should_capture_child_tasks_in_placement_group
|
||||
)
|
||||
|
||||
if should_create_placement_group:
|
||||
bundles = [
|
||||
self._resources_per_worker.copy() for _ in range(self._num_workers)
|
||||
]
|
||||
|
||||
use_spread = bool(env_integer(TRAIN_ENABLE_WORKER_SPREAD_ENV, 0))
|
||||
strategy = "SPREAD" if use_spread else "PACK"
|
||||
|
||||
placement_group = ray.util.placement_group(bundles, strategy=strategy)
|
||||
logger.debug("Waiting for placement group to start.")
|
||||
timeout = env_integer(TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV, 100)
|
||||
ready, _ = ray.wait([placement_group.ready()], timeout=timeout)
|
||||
if ready:
|
||||
logger.debug("Placement group has started.")
|
||||
else:
|
||||
raise TimeoutError(
|
||||
"Placement group creation timed out. Make sure your "
|
||||
"cluster either has enough resources or use an "
|
||||
"autoscaling cluster. If you are running on a cluster, "
|
||||
"make sure you specify an address in `ray.init()`, for example, "
|
||||
'`ray.init("auto")`. You can also increase the timeout by setting '
|
||||
"the TRAIN_PLACEMENT_GROUP_TIMEOUT_S environment variable. "
|
||||
"Current resources available: {}, resources requested by the "
|
||||
"placement group: {}".format(
|
||||
ray.available_resources(), placement_group.bundle_specs
|
||||
)
|
||||
)
|
||||
self._placement_group = placement_group
|
||||
|
||||
def _share_cuda_visible_devices(self):
|
||||
"""Sets CUDA_VISIBLE_DEVICES on all workers.
|
||||
|
||||
For each worker, CUDA_VISIBLE_DEVICES will be set to the GPU IDs
|
||||
visible to all workers on that worker's node.
|
||||
|
||||
This allows GPU workers on the same node to communicate with one
|
||||
another.
|
||||
|
||||
Example:
|
||||
|
||||
Setup:
|
||||
- Node1:
|
||||
- Worker1: {0, 1}
|
||||
- Worker2: {2, 3}
|
||||
- Node2:
|
||||
- Worker3: {0, 1}
|
||||
|
||||
CUDA_VISIBLE_DEVICES:
|
||||
- Worker1: "0,1,2,3"
|
||||
- Worker2: "0,1,2,3"
|
||||
- Worker3: "0,1"
|
||||
|
||||
"""
|
||||
self._share_resource_ids(ray_constants.GPU, CUDA_VISIBLE_DEVICES_ENV_VAR)
|
||||
|
||||
def _share_resource_ids(self, resource: str, env_var: str):
|
||||
"""Sets the given env_var on all workers.
|
||||
|
||||
For each worker, the cores/devices are visible to all the
|
||||
workers on that worker's node.This allows workers on the
|
||||
same node to communicate with one another.
|
||||
|
||||
Example:
|
||||
|
||||
Setup:
|
||||
- Node1:
|
||||
- Worker1: {0, 1}
|
||||
- Worker2: {2, 3}
|
||||
- Node2:
|
||||
- Worker3: {0, 1}
|
||||
|
||||
NEURON_RT_VISIBLE_CORES/TPU_VISIBLE_CHIPS/...:
|
||||
- Worker1: "0,1,2,3"
|
||||
- Worker2: "0,1,2,3"
|
||||
- Worker2: "0,1"
|
||||
|
||||
Args:
|
||||
resource: The name of the resource/accelerator.
|
||||
env_var: The name of the environment variable to set.
|
||||
"""
|
||||
node_ids_and_resource_ids = [
|
||||
(
|
||||
w.metadata.node_id,
|
||||
w.metadata.resource_ids[resource],
|
||||
)
|
||||
for w in self.worker_group.workers
|
||||
]
|
||||
node_id_to_worker_id = defaultdict(set)
|
||||
node_id_to_resource_ids = defaultdict(set)
|
||||
|
||||
for worker_id, (node_id, resource_ids) in enumerate(node_ids_and_resource_ids):
|
||||
node_id_to_worker_id[node_id].add(worker_id)
|
||||
node_id_to_resource_ids[node_id].update(resource_ids)
|
||||
|
||||
futures = []
|
||||
for node_id, resource_ids in node_id_to_resource_ids.items():
|
||||
resource_ids = sorted(resource_ids)
|
||||
all_resource_ids = ",".join(resource_ids)
|
||||
|
||||
def set_resource_ids():
|
||||
os.environ[env_var] = all_resource_ids
|
||||
|
||||
for worker_id in node_id_to_worker_id[node_id]:
|
||||
futures.append(
|
||||
self.worker_group.execute_single_async(worker_id, set_resource_ids)
|
||||
)
|
||||
ray.get(futures)
|
||||
|
||||
def _is_share_resources_enabled(self, resource_name: str, enable_sharing_env: str):
|
||||
"""Whether to share resource IDs on all workers
|
||||
based on enable_sharing_env.
|
||||
|
||||
This will return true if resources are requested and greater than 0.
|
||||
Also, user can disable by configuring the `enable_sharing_env` to "0".
|
||||
|
||||
Args:
|
||||
resource_name: The name of the resource/accelerator.
|
||||
enable_sharing_env: The name of the environment variable
|
||||
to check.
|
||||
|
||||
Returns:
|
||||
True if resource sharing is enabled, False otherwise.
|
||||
"""
|
||||
has_resource_requested = self._resources_per_worker.get(resource_name, 0) > 0
|
||||
return has_resource_requested and ray_constants.env_bool(
|
||||
enable_sharing_env, True
|
||||
)
|
||||
|
||||
def _create_rank_world_size_mappings(
|
||||
self,
|
||||
) -> Tuple[Dict[int, int], Dict[int, int], Dict[int, int]]:
|
||||
"""Create rank and world size mappings for workers.
|
||||
|
||||
There are three maps returned:
|
||||
- local_rank_map, which maps from worker world_rank to local_rank.
|
||||
- local_world_size_map, which maps from world_rank to local_world_size
|
||||
- node_rank_map, which maps from world rank to node rank
|
||||
|
||||
Example:
|
||||
Worker 0: node 0
|
||||
Worker 1: node 0
|
||||
Worker 2: node 1
|
||||
Worker 3: node 0
|
||||
Worker 4: node 1
|
||||
|
||||
Workers 0, 1, 3 are on node 0.
|
||||
Workers 2, 4 are on node 1.
|
||||
|
||||
Expected local_rank_map:
|
||||
{
|
||||
0 -> 0,
|
||||
1 -> 1,
|
||||
2 -> 0,
|
||||
3 -> 2,
|
||||
4 -> 1
|
||||
}
|
||||
|
||||
Expected local_world_size_map:
|
||||
{
|
||||
0 -> 3,
|
||||
1 -> 3,
|
||||
2 -> 2,
|
||||
3 -> 3,
|
||||
4 -> 2
|
||||
}
|
||||
|
||||
Expected node_rank_map:
|
||||
{
|
||||
0 -> 0,
|
||||
1 -> 0,
|
||||
2 -> 1,
|
||||
3 -> 0,
|
||||
4 -> 1
|
||||
}
|
||||
|
||||
Returns:
|
||||
A tuple of (local_rank_map, local_world_size_map, node_rank_map).
|
||||
"""
|
||||
local_rank_map = {} # map from world rank to local rank
|
||||
local_world_size_map = {} # map from world rank to local world size
|
||||
node_rank_map = {} # map from world rank to node rank
|
||||
node_ids = {} # map from node id to node index
|
||||
node_cnt = 0 # count the number of nodes
|
||||
|
||||
node_id_dict = defaultdict(
|
||||
int
|
||||
) # map from node id to the number of workers on it.
|
||||
for world_rank in range(len(self.worker_group)):
|
||||
worker = self.worker_group.workers[world_rank]
|
||||
node_id = worker.metadata.node_id
|
||||
local_rank_map[world_rank] = node_id_dict[node_id]
|
||||
node_id_dict[node_id] += 1
|
||||
|
||||
if node_id not in node_ids:
|
||||
node_ids[node_id] = node_cnt
|
||||
node_cnt += 1
|
||||
node_rank_map[world_rank] = node_ids[node_id]
|
||||
|
||||
for world_rank in range(len(self.worker_group)):
|
||||
worker = self.worker_group.workers[world_rank]
|
||||
node_id = worker.metadata.node_id
|
||||
local_world_size_map[world_rank] = node_id_dict[node_id]
|
||||
|
||||
workers_info = "\n".join(
|
||||
[
|
||||
f"- (node_id={w.metadata.node_id}, ip={w.metadata.node_ip}, "
|
||||
f"pid={w.metadata.pid}) world_rank={i}, "
|
||||
f"local_rank={local_rank_map[i]}, node_rank={node_rank_map[i]}"
|
||||
for i, w in enumerate(self.worker_group.workers)
|
||||
]
|
||||
)
|
||||
logger.info(f"Started distributed worker processes: \n{workers_info}")
|
||||
|
||||
return local_rank_map, local_world_size_map, node_rank_map
|
||||
|
||||
def start_training(
|
||||
self,
|
||||
train_func: Callable[[], T],
|
||||
datasets: Dict[str, "Dataset"],
|
||||
metadata: Dict[str, Any],
|
||||
data_config: DataConfig,
|
||||
storage: StorageContext,
|
||||
checkpoint: Optional[Checkpoint] = None,
|
||||
) -> None:
|
||||
"""Executes a training function on all workers in a separate thread.
|
||||
|
||||
``finish_training`` should be called after this.
|
||||
|
||||
Args:
|
||||
train_func: The training function to run on each worker.
|
||||
datasets: The base datasets.
|
||||
metadata: User-supplied metadata dict propagated to checkpoints
|
||||
created during training.
|
||||
data_config: The config object for creating dataset shards for workers.
|
||||
storage: The storage context, providing access to the experiment
|
||||
directory and other persistent storage state.
|
||||
checkpoint: The checkpoint data that
|
||||
should be loaded onto each worker and accessed by the
|
||||
training function via ``session.get_checkpoint()``. If this
|
||||
is ``None`` then no checkpoint will be loaded.
|
||||
"""
|
||||
use_detailed_autofilled_metrics = env_integer(
|
||||
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV, 0
|
||||
)
|
||||
|
||||
# First initialize the session.
|
||||
def initialize_session(
|
||||
train_func,
|
||||
world_rank,
|
||||
local_rank,
|
||||
node_rank,
|
||||
local_world_size,
|
||||
world_size,
|
||||
trial_info,
|
||||
checkpoint,
|
||||
dataset_shard,
|
||||
metadata,
|
||||
storage,
|
||||
):
|
||||
try:
|
||||
init_session(
|
||||
training_func=train_func,
|
||||
world_rank=world_rank,
|
||||
local_rank=local_rank,
|
||||
node_rank=node_rank,
|
||||
local_world_size=local_world_size,
|
||||
world_size=world_size,
|
||||
trial_info=trial_info,
|
||||
dataset_shard=dataset_shard,
|
||||
metadata=metadata,
|
||||
checkpoint=checkpoint,
|
||||
detailed_autofilled_metrics=use_detailed_autofilled_metrics,
|
||||
storage=storage,
|
||||
)
|
||||
except ValueError:
|
||||
raise TrainBackendError(
|
||||
"Attempting to start training but a "
|
||||
"previous training run is still ongoing. "
|
||||
"You must call `finish_training` before "
|
||||
"calling `start_training` again."
|
||||
)
|
||||
|
||||
if self.dataset_shards is None:
|
||||
actors = [worker.actor for worker in self.worker_group.workers]
|
||||
node_ids = [worker.metadata.node_id for worker in self.worker_group.workers]
|
||||
self.dataset_shards = data_config.configure(
|
||||
datasets,
|
||||
world_size=len(self.worker_group),
|
||||
worker_handles=actors,
|
||||
worker_node_ids=node_ids,
|
||||
)
|
||||
|
||||
(
|
||||
local_rank_map,
|
||||
local_world_size_map,
|
||||
node_rank_map,
|
||||
) = self._create_rank_world_size_mappings()
|
||||
|
||||
futures = []
|
||||
for index in range(len(self.worker_group)):
|
||||
futures.append(
|
||||
self.worker_group.execute_single_async(
|
||||
index,
|
||||
initialize_session,
|
||||
world_rank=index,
|
||||
local_rank=local_rank_map[index],
|
||||
node_rank=node_rank_map[index],
|
||||
local_world_size=local_world_size_map[index],
|
||||
world_size=len(self.worker_group),
|
||||
trial_info=self._trial_info,
|
||||
train_func=train_func,
|
||||
dataset_shard=self.dataset_shards[index],
|
||||
metadata=metadata,
|
||||
checkpoint=checkpoint,
|
||||
storage=storage,
|
||||
)
|
||||
)
|
||||
|
||||
self._backend.on_training_start(self.worker_group, self._backend_config)
|
||||
|
||||
self.get_with_failure_handling(futures)
|
||||
|
||||
# Register Train Run before training starts
|
||||
if self.state_tracking_enabled:
|
||||
from ray.train._internal.state.schema import RunStatusEnum
|
||||
|
||||
core_context = ray.runtime_context.get_runtime_context()
|
||||
|
||||
self.state_manager.register_train_run(
|
||||
run_id=self._trial_info.run_id,
|
||||
run_name=self._trial_info.experiment_name,
|
||||
job_id=core_context.get_job_id(),
|
||||
controller_actor_id=core_context.get_actor_id(),
|
||||
datasets=datasets,
|
||||
worker_group=self.worker_group,
|
||||
start_time_ms=self._start_time_ms,
|
||||
run_status=RunStatusEnum.RUNNING,
|
||||
resources=[self._resources_per_worker] * self._num_workers,
|
||||
)
|
||||
|
||||
# Run the training function asynchronously in its own thread.
|
||||
def train_async():
|
||||
session = get_session()
|
||||
session.start()
|
||||
|
||||
self.worker_group.execute_async(train_async)
|
||||
|
||||
def get_next_results(self) -> Optional[List[_TrainingResult]]:
|
||||
"""Fetches the next ``_TrainingResult`` from each worker.
|
||||
|
||||
Each ``_TrainingResult`` is expected to correspond to the same step from
|
||||
each worker (e.g. the same call to ``train.report()``).
|
||||
|
||||
Returns:
|
||||
A list of ``_TrainingResult``s or ``None`` if there are no more results
|
||||
since the training function has exited on all workers.
|
||||
"""
|
||||
|
||||
def get_next():
|
||||
session = _get_session("get_next_results")
|
||||
try:
|
||||
result = session.get_next()
|
||||
except RuntimeError:
|
||||
# Training thread has not been started yet.
|
||||
raise TrainBackendError(
|
||||
"`get_next_results` has been called "
|
||||
"before `start_training`. Please call "
|
||||
"`start_training` before "
|
||||
"`get_next_results`."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# Get next result from each worker.
|
||||
futures = self.worker_group.execute_async(get_next)
|
||||
results = self.get_with_failure_handling(futures)
|
||||
|
||||
# Check if any worker returned None.
|
||||
if any(r is None for r in results):
|
||||
# Either all workers have results or none of them do.
|
||||
if not all(r is None for r in results):
|
||||
raise RuntimeError(
|
||||
"Some workers returned results while "
|
||||
"others didn't. Make sure that "
|
||||
"`session.report()` are called the "
|
||||
"same number of times on all workers."
|
||||
)
|
||||
else:
|
||||
# Return None if all results are None.
|
||||
return None
|
||||
|
||||
return results
|
||||
|
||||
def pause_reporting(self):
|
||||
"""Disable workers from enqueuing results from ``session.report()``.
|
||||
|
||||
Note: Already reported results may still be enqueued at this point,
|
||||
and should be handled appropriately.
|
||||
"""
|
||||
|
||||
def pause_session_reporting():
|
||||
session = _get_session("pause_reporting")
|
||||
return session.pause_reporting()
|
||||
|
||||
futures = self.worker_group.execute_async(pause_session_reporting)
|
||||
self.get_with_failure_handling(futures)
|
||||
|
||||
def finish_training(self):
|
||||
"""Finish training and return final results. Propagate any exceptions.
|
||||
|
||||
Blocks until training is finished on all workers.
|
||||
|
||||
Assumes `start_training` has already been called.
|
||||
|
||||
Returns:
|
||||
A list of return values from calling ``train_func`` on each worker.
|
||||
Each item corresponds to the return value from a single worker.
|
||||
"""
|
||||
|
||||
def end_training():
|
||||
session = _get_session("finish_training")
|
||||
try:
|
||||
# session.finish raises any Exceptions from training.
|
||||
output = session.finish()
|
||||
finally:
|
||||
# Shutdown session even if session.finish() raises an
|
||||
# Exception.
|
||||
shutdown_session()
|
||||
|
||||
return output
|
||||
|
||||
futures = self.worker_group.execute_async(end_training)
|
||||
results = self.get_with_failure_handling(futures)
|
||||
return results
|
||||
|
||||
def report_final_run_status(
|
||||
self,
|
||||
errored: bool = False,
|
||||
failed_rank: Optional[int] = None,
|
||||
stack_trace: Optional[str] = None,
|
||||
):
|
||||
"""Report the final train run status, error, and end time to TrainStateActor."""
|
||||
if self.state_tracking_enabled:
|
||||
from ray.train._internal.state.schema import (
|
||||
MAX_ERROR_STACK_TRACE_LENGTH,
|
||||
RunStatusEnum,
|
||||
)
|
||||
|
||||
if errored:
|
||||
run_status = RunStatusEnum.ERRORED
|
||||
status_detail = ""
|
||||
if failed_rank is not None:
|
||||
status_detail += f"Rank {failed_rank} worker raised an error. \n"
|
||||
if stack_trace is not None:
|
||||
# Keep only the last part of the stack trace if it's too long.
|
||||
status_detail += stack_trace[-MAX_ERROR_STACK_TRACE_LENGTH:]
|
||||
else:
|
||||
run_status = RunStatusEnum.FINISHED
|
||||
status_detail = ""
|
||||
|
||||
self.state_manager.end_train_run(
|
||||
run_id=self._trial_info.run_id,
|
||||
run_status=run_status,
|
||||
status_detail=status_detail,
|
||||
end_time_ms=int(time.time() * 1000),
|
||||
)
|
||||
|
||||
def get_with_failure_handling(self, remote_values: List[ray.ObjectRef]):
|
||||
"""Gets the remote values while handling for worker failures.
|
||||
|
||||
This method should be called instead of ``ray.get()`` directly in
|
||||
order to handle worker failures.
|
||||
|
||||
If a worker failure is identified, backend specific failure handling
|
||||
is executed and a ``TrainingWorkerError`` is raised.
|
||||
|
||||
Args:
|
||||
remote_values: List of object refs representing functions
|
||||
that may fail in the middle of execution. For example, running
|
||||
a Train training loop in multiple parallel actor calls.
|
||||
Returns:
|
||||
The resolved objects represented by the passed in ObjectRefs.
|
||||
"""
|
||||
success, exception = check_for_failure(remote_values)
|
||||
if success:
|
||||
return ray.get(remote_values)
|
||||
else:
|
||||
self._last_failure = exception
|
||||
self._increment_failures()
|
||||
logger.warning(
|
||||
"Failure identified during training. Restarting all workers and "
|
||||
"continuing training from latest checkpoint."
|
||||
)
|
||||
self._restart()
|
||||
raise TrainingWorkerError
|
||||
|
||||
def shutdown(self, graceful_termination: bool = True):
|
||||
"""Shuts down the workers in the worker group.
|
||||
|
||||
Args:
|
||||
graceful_termination: If set to True, attempt to clean up the backend
|
||||
before terminating the Ray actors.
|
||||
|
||||
"""
|
||||
if graceful_termination:
|
||||
try:
|
||||
self._backend.on_shutdown(self.worker_group, self._backend_config)
|
||||
except RayActorError:
|
||||
logger.warning(
|
||||
"Graceful shutdown of backend failed. This is "
|
||||
"expected if one of the workers has crashed."
|
||||
)
|
||||
|
||||
if graceful_termination:
|
||||
self.worker_group.shutdown()
|
||||
else:
|
||||
self.worker_group.shutdown(patience_s=0)
|
||||
self.worker_group = InactiveWorkerGroup()
|
||||
|
||||
if self._placement_group:
|
||||
remove_placement_group(self._placement_group)
|
||||
self._placement_group = None
|
||||
|
||||
self.dataset_shards = None
|
||||
|
||||
def is_started(self):
|
||||
return not isinstance(self.worker_group, InactiveWorkerGroup)
|
||||
|
||||
def _restart(self):
|
||||
self.worker_group.shutdown()
|
||||
if self._initialization_hook is not None:
|
||||
initialization_hook = self._initialization_hook
|
||||
else:
|
||||
initialization_hook = None
|
||||
if self._placement_group:
|
||||
remove_placement_group(self._placement_group)
|
||||
self._placement_group = None
|
||||
self.start(initialization_hook=initialization_hook)
|
||||
|
||||
def _increment_failures(self):
|
||||
self._num_failures += 1
|
||||
if self._num_failures >= self._max_failures:
|
||||
failure = self._last_failure
|
||||
self._last_failure = None
|
||||
if self._max_failures > 0:
|
||||
exc = RuntimeError(
|
||||
f"Training has failed after {self._num_failures} attempts."
|
||||
)
|
||||
raise exc.with_traceback(None) from failure
|
||||
else:
|
||||
raise failure
|
||||
|
||||
def get_worker_group(self):
|
||||
return self.worker_group
|
||||
|
||||
def _get_num_failures(self):
|
||||
return self._num_failures
|
||||
|
||||
|
||||
class InactiveWorkerGroupError(Exception):
|
||||
"""Raised when underlying worker group is inactive."""
|
||||
|
||||
|
||||
class InactiveWorkerGroup:
|
||||
# TODO: fix inheritence. perhaps create WorkerGroupInterface.
|
||||
|
||||
# Need to define getstate and setstate so that getattr does not screwup
|
||||
# pickling. See https://stackoverflow.com/a/50888571/11249691
|
||||
def __getstate__(self):
|
||||
return vars(self)
|
||||
|
||||
def __setstate__(self, state):
|
||||
vars(self).update(state)
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise InactiveWorkerGroupError()
|
||||
|
||||
def __len__(self):
|
||||
raise InactiveWorkerGroupError()
|
||||
|
||||
|
||||
def _get_session(method_name: str):
|
||||
# Get the session for this worker.
|
||||
session = get_session()
|
||||
if not session:
|
||||
# Session is not initialized yet.
|
||||
raise TrainBackendError(
|
||||
f"`{method_name}` has been called "
|
||||
"before `start_training`. Please call "
|
||||
"`start_training` before "
|
||||
f"`{method_name}`."
|
||||
)
|
||||
return session
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Abstract base class for WorkerGroup implementations.
|
||||
|
||||
This module defines the common base class that both V1 and V2 WorkerGroup
|
||||
implementations should inherit from to ensure backend compatibility.
|
||||
"""
|
||||
|
||||
import abc
|
||||
from typing import Callable, List, TypeVar
|
||||
|
||||
from ray.types import ObjectRef
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BaseWorkerGroup(abc.ABC):
|
||||
"""Abstract base class for WorkerGroup implementations.
|
||||
|
||||
This base class defines the minimal set of methods that backend classes
|
||||
expect from WorkerGroup implementations. Both V1 and V2 WorkerGroup
|
||||
classes should inherit from this base class to ensure compatibility with
|
||||
all backend configurations.
|
||||
|
||||
The interface focuses on the core operations that backends need:
|
||||
- Executing functions on workers
|
||||
- Getting worker count and resource allocation
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute(self, func: Callable[..., T], *args, **kwargs) -> List[T]:
|
||||
"""Execute a function on all workers synchronously.
|
||||
|
||||
Args:
|
||||
func: The function to execute on each worker.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
A list of results from each worker, in worker rank order.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute_async(self, func: Callable[..., T], *args, **kwargs) -> List[ObjectRef]:
|
||||
"""Execute a function on all workers asynchronously.
|
||||
|
||||
Args:
|
||||
func: The function to execute on each worker.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
A list of ObjectRef results from each worker, in worker rank order.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute_single(
|
||||
self, worker_index: int, func: Callable[..., T], *args, **kwargs
|
||||
) -> T:
|
||||
"""Execute a function on a single worker synchronously.
|
||||
|
||||
Args:
|
||||
worker_index: The index of the worker to execute on.
|
||||
func: The function to execute.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result from the specified worker.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute_single_async(
|
||||
self, worker_index: int, func: Callable[..., T], *args, **kwargs
|
||||
) -> ObjectRef:
|
||||
"""Execute a function on a single worker asynchronously.
|
||||
|
||||
Args:
|
||||
worker_index: The index of the worker to execute on.
|
||||
func: The function to execute.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
An ObjectRef to the result from the specified worker.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of workers in the group."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_resources_per_worker(self) -> dict:
|
||||
"""Get the resources allocated per worker.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping resource names to quantities per worker.
|
||||
Common keys include "CPU", "GPU", "memory".
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,206 @@
|
||||
import logging
|
||||
import numbers
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ray._private import ray_constants
|
||||
from ray._private.dict import flatten_dict
|
||||
from ray.air._internal.util import is_nan
|
||||
from ray.air.config import MAX
|
||||
from ray.train import Checkpoint, CheckpointConfig
|
||||
from ray.train._internal.session import _TrainingResult
|
||||
from ray.train._internal.storage import _delete_fs_path
|
||||
from ray.train.constants import TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _insert_into_sorted_list(
|
||||
list: List[_TrainingResult],
|
||||
item: _TrainingResult,
|
||||
key: Callable[[_TrainingResult], Any],
|
||||
checkpoint_to_report_index: Optional[Dict[Checkpoint, int]] = None,
|
||||
):
|
||||
"""Insert an item into a sorted list with a custom key function.
|
||||
|
||||
Args:
|
||||
list: The list to insert the item into.
|
||||
item: The item to insert.
|
||||
key: The key function to use to sort the list.
|
||||
checkpoint_to_report_index: A dictionary mapping checkpoints to report indices.
|
||||
Used to break ties when scores are equal.
|
||||
"""
|
||||
checkpoint_to_report_index = checkpoint_to_report_index or {}
|
||||
# TODO: optimize this with sortedlist, batching, etc
|
||||
i = 0
|
||||
while i < len(list):
|
||||
# When scores are equal, later checkpoints are later in the list.
|
||||
list_item_key, item_key = key(list[i]), key(item)
|
||||
if list_item_key > item_key or (
|
||||
list_item_key == item_key
|
||||
and checkpoint_to_report_index.get(list[i].checkpoint, 0)
|
||||
> checkpoint_to_report_index.get(item.checkpoint, 0)
|
||||
):
|
||||
break
|
||||
i += 1
|
||||
list.insert(i, item)
|
||||
|
||||
|
||||
class _CheckpointManager:
|
||||
"""Checkpoint manager that handles checkpoint book-keeping for a trial.
|
||||
|
||||
The main purpose of this abstraction is to keep the top K checkpoints based on
|
||||
recency/a user-provided metric.
|
||||
|
||||
NOTE: This class interacts with `_TrainingResult` objects, which are
|
||||
(checkpoint, metrics) pairs. This is to order checkpoints by metrics.
|
||||
|
||||
Args:
|
||||
checkpoint_config: Defines how many and which checkpoints to keep.
|
||||
"""
|
||||
|
||||
def __init__(self, checkpoint_config: Optional[CheckpointConfig]):
|
||||
self._checkpoint_config = checkpoint_config or CheckpointConfig()
|
||||
|
||||
# List of checkpoints ordered by ascending score.
|
||||
self._checkpoint_results: List[_TrainingResult] = []
|
||||
|
||||
# The latest registered checkpoint.
|
||||
# This should never be immediately deleted upon registration,
|
||||
# even if it's not in the top K checkpoints, based on score.
|
||||
self._latest_checkpoint_result: Optional[_TrainingResult] = None
|
||||
|
||||
if (
|
||||
self._checkpoint_config.num_to_keep is not None
|
||||
and self._checkpoint_config.num_to_keep <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"`num_to_keep` must >= 1, got: "
|
||||
f"{self._checkpoint_config.num_to_keep}"
|
||||
)
|
||||
|
||||
@property
|
||||
def checkpoint_config(self):
|
||||
return self._checkpoint_config
|
||||
|
||||
def register_checkpoint(self, checkpoint_result: _TrainingResult):
|
||||
"""Register new checkpoint and add to bookkeeping.
|
||||
|
||||
This method will register a new checkpoint and add it to the internal
|
||||
bookkeeping logic. This means the checkpoint manager will decide if
|
||||
this checkpoint should be kept, and if older or worse performing
|
||||
checkpoints should be deleted.
|
||||
|
||||
Args:
|
||||
checkpoint_result: Tracked training result containing the checkpoint
|
||||
and associated metrics to add to bookkeeping.
|
||||
"""
|
||||
self._latest_checkpoint_result = checkpoint_result
|
||||
|
||||
score_attr = self._checkpoint_config.checkpoint_score_attribute
|
||||
if ray_constants.env_bool(TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE, False):
|
||||
metrics = (
|
||||
{score_attr: checkpoint_result.metrics[score_attr]}
|
||||
if score_attr in checkpoint_result.metrics
|
||||
else {}
|
||||
)
|
||||
checkpoint_result = _TrainingResult(
|
||||
checkpoint=checkpoint_result.checkpoint,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
if score_attr is not None and score_attr in checkpoint_result.metrics:
|
||||
# If we're ordering by a score, insert the checkpoint
|
||||
# so that the list remains sorted.
|
||||
_insert_into_sorted_list(
|
||||
self._checkpoint_results,
|
||||
checkpoint_result,
|
||||
key=self._get_checkpoint_score,
|
||||
)
|
||||
else:
|
||||
# If no metric is provided, just append (ordering by time of registration).
|
||||
self._checkpoint_results.append(checkpoint_result)
|
||||
|
||||
if self._checkpoint_config.num_to_keep is not None:
|
||||
# Delete the bottom (N - K) checkpoints
|
||||
worst_results = set(
|
||||
self._checkpoint_results[: -self._checkpoint_config.num_to_keep]
|
||||
)
|
||||
# Except for the latest checkpoint.
|
||||
results_to_delete = worst_results - {self._latest_checkpoint_result}
|
||||
|
||||
# Update internal state before actually deleting them.
|
||||
self._checkpoint_results = [
|
||||
checkpoint_result
|
||||
for checkpoint_result in self._checkpoint_results
|
||||
if checkpoint_result not in results_to_delete
|
||||
]
|
||||
|
||||
for checkpoint_result in results_to_delete:
|
||||
checkpoint = checkpoint_result.checkpoint
|
||||
logger.debug("Deleting checkpoint: %s", checkpoint)
|
||||
_delete_fs_path(fs=checkpoint.filesystem, fs_path=checkpoint.path)
|
||||
|
||||
def _get_checkpoint_score(
|
||||
self, checkpoint: _TrainingResult
|
||||
) -> Tuple[bool, numbers.Number]:
|
||||
"""Get the score for a checkpoint, according to checkpoint config.
|
||||
|
||||
If `mode="min"`, the metric is negated so that the lowest score is
|
||||
treated as the best.
|
||||
|
||||
Args:
|
||||
checkpoint: The training result whose metrics should be scored.
|
||||
|
||||
Returns:
|
||||
Tuple: A tuple of (not_is_nan: bool, score: numbers.Number).
|
||||
This score orders: nan values < float("-inf") < valid numeric metrics
|
||||
"""
|
||||
checkpoint_score_attribute = self._checkpoint_config.checkpoint_score_attribute
|
||||
if checkpoint_score_attribute:
|
||||
flat_metrics = flatten_dict(checkpoint.metrics)
|
||||
try:
|
||||
checkpoint_result = flat_metrics[checkpoint_score_attribute]
|
||||
except KeyError:
|
||||
valid_keys = list(flat_metrics.keys())
|
||||
logger.error(
|
||||
f"Result dict has no key: {checkpoint_score_attribute}. "
|
||||
f"checkpoint_score_attr must be set to a key in the "
|
||||
f"result dict. Valid keys are: {valid_keys}"
|
||||
)
|
||||
checkpoint_result = float("-inf")
|
||||
else:
|
||||
checkpoint_result = float("-inf")
|
||||
|
||||
checkpoint_score_order = self._checkpoint_config.checkpoint_score_order
|
||||
order_factor = 1.0 if checkpoint_score_order == MAX else -1.0
|
||||
|
||||
checkpoint_score = order_factor * checkpoint_result
|
||||
|
||||
if not isinstance(checkpoint_score, numbers.Number):
|
||||
raise ValueError(
|
||||
f"Unable to persist checkpoint for "
|
||||
f"checkpoint_score_attribute: "
|
||||
f"{checkpoint_score_attribute} with value "
|
||||
f"{checkpoint_score}. "
|
||||
f"This attribute must be numerical."
|
||||
)
|
||||
|
||||
return (
|
||||
(not is_nan(checkpoint_score), checkpoint_score)
|
||||
if not is_nan(checkpoint_score)
|
||||
else (False, float("-inf"))
|
||||
)
|
||||
|
||||
@property
|
||||
def best_checkpoint_result(self) -> Optional[_TrainingResult]:
|
||||
return self._checkpoint_results[-1] if self._checkpoint_results else None
|
||||
|
||||
@property
|
||||
def latest_checkpoint_result(self) -> Optional[_TrainingResult]:
|
||||
return self._latest_checkpoint_result
|
||||
|
||||
@property
|
||||
def best_checkpoint_results(self) -> List[_TrainingResult]:
|
||||
if self._checkpoint_config.num_to_keep is None:
|
||||
return self._checkpoint_results
|
||||
return self._checkpoint_results[-self._checkpoint_config.num_to_keep :]
|
||||
@@ -0,0 +1,178 @@
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union
|
||||
|
||||
from ray.actor import ActorHandle
|
||||
from ray.util.annotations import DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import DataIterator, Dataset, ExecutionOptions, NodeIdStr
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class DataConfig:
|
||||
"""Class responsible for configuring Train dataset preprocessing.
|
||||
|
||||
For advanced use cases, this class can be subclassed and the `configure()` method
|
||||
overridden for custom data preprocessing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datasets_to_split: Union[Literal["all"], List[str]] = "all",
|
||||
execution_options: Optional[
|
||||
Union["ExecutionOptions", Dict[str, "ExecutionOptions"]]
|
||||
] = None,
|
||||
enable_shard_locality: bool = True,
|
||||
):
|
||||
"""Construct a DataConfig.
|
||||
|
||||
Args:
|
||||
datasets_to_split: Specifies which datasets should be split among workers.
|
||||
Can be set to "all" or a list of dataset names. Defaults to "all",
|
||||
i.e. split all datasets.
|
||||
execution_options: The execution options to pass to Ray Data. Can be either:
|
||||
1. A single ExecutionOptions object that is applied to all datasets.
|
||||
2. A dict mapping dataset names to ExecutionOptions. If a dataset name
|
||||
is not in the dict, it defaults to ``DataConfig.default_ingest_options()``.
|
||||
By default, the options are optimized for data ingest. When overriding,
|
||||
base your options off ``DataConfig.default_ingest_options()``.
|
||||
enable_shard_locality: If true, dataset sharding across Train workers will
|
||||
consider locality to minimize cross-node data transfer. Enabled by default.
|
||||
"""
|
||||
from ray.data import ExecutionOptions
|
||||
|
||||
if isinstance(datasets_to_split, list) or datasets_to_split == "all":
|
||||
self._datasets_to_split = datasets_to_split
|
||||
else:
|
||||
raise TypeError(
|
||||
"`datasets_to_split` should be a 'all' or a list of strings of "
|
||||
"dataset names. Received "
|
||||
f"{type(datasets_to_split).__name__} with value {datasets_to_split}."
|
||||
)
|
||||
|
||||
default_execution_options = DataConfig.default_ingest_options()
|
||||
|
||||
if isinstance(execution_options, ExecutionOptions):
|
||||
default_execution_options = execution_options
|
||||
# If None, all datasets will use the default ingest options.
|
||||
self._execution_options: Dict[str, "ExecutionOptions"] = defaultdict(
|
||||
lambda: copy.deepcopy(default_execution_options)
|
||||
)
|
||||
if isinstance(execution_options, dict):
|
||||
self._execution_options.update(execution_options)
|
||||
|
||||
self._enable_shard_locality = enable_shard_locality
|
||||
|
||||
self._num_train_cpus = 0.0
|
||||
self._num_train_gpus = 0.0
|
||||
|
||||
def set_train_total_resources(self, num_train_cpus: float, num_train_gpus: float):
|
||||
"""Set the total number of CPUs and GPUs used by training.
|
||||
|
||||
If CPU or GPU resource limits are not set, they will be set to the
|
||||
total cluster resources minus the resources used by training.
|
||||
"""
|
||||
# TODO: We may also include other resources besides CPU and GPU.
|
||||
self._num_train_cpus = num_train_cpus
|
||||
self._num_train_gpus = num_train_gpus
|
||||
|
||||
def _get_execution_options(self, dataset_name: str) -> "ExecutionOptions":
|
||||
"""Return a copy of the configured execution options for a given dataset name."""
|
||||
return copy.deepcopy(self._execution_options[dataset_name])
|
||||
|
||||
@DeveloperAPI
|
||||
def configure(
|
||||
self,
|
||||
datasets: Dict[str, "Dataset"],
|
||||
world_size: int,
|
||||
worker_handles: Optional[List[ActorHandle]],
|
||||
worker_node_ids: Optional[List["NodeIdStr"]],
|
||||
**kwargs,
|
||||
) -> List[Dict[str, "DataIterator"]]:
|
||||
"""Configure how Train datasets should be assigned to workers.
|
||||
|
||||
Args:
|
||||
datasets: The datasets dict passed to Train by the user.
|
||||
world_size: The number of Train workers in total.
|
||||
worker_handles: The actor handles of the Train workers.
|
||||
worker_node_ids: The node ids of the Train workers.
|
||||
**kwargs: Forwards compatibility placeholder.
|
||||
|
||||
Returns:
|
||||
A list of dataset splits for each worker. The size of the list must be
|
||||
equal to `world_size`. Each element of the list contains the assigned
|
||||
`DataIterator` instances by name for the worker.
|
||||
"""
|
||||
from ray.data._internal.execution.interfaces.execution_options import (
|
||||
ExecutionResources,
|
||||
)
|
||||
|
||||
output = [{} for _ in range(world_size)]
|
||||
|
||||
for dataset_name, dataset in datasets.items():
|
||||
if dataset.name is None:
|
||||
dataset.set_name(dataset_name)
|
||||
|
||||
if self._datasets_to_split == "all":
|
||||
datasets_to_split = set(datasets.keys())
|
||||
else:
|
||||
datasets_to_split = set(self._datasets_to_split)
|
||||
|
||||
locality_hints = worker_node_ids if self._enable_shard_locality else None
|
||||
for name, ds in datasets.items():
|
||||
execution_options = self._get_execution_options(name)
|
||||
|
||||
if execution_options.is_resource_limits_default():
|
||||
if not self._scaling_policy_reserves_train_resources():
|
||||
execution_options.exclude_resources = (
|
||||
execution_options.exclude_resources.add(
|
||||
ExecutionResources(
|
||||
cpu=self._num_train_cpus, gpu=self._num_train_gpus
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
ds = ds.copy(ds)
|
||||
ds.context.execution_options = execution_options
|
||||
|
||||
if name in datasets_to_split:
|
||||
for i, split in enumerate(
|
||||
ds.streaming_split(
|
||||
world_size, equal=True, locality_hints=locality_hints
|
||||
)
|
||||
):
|
||||
output[i][name] = split
|
||||
else:
|
||||
for i in range(world_size):
|
||||
output[i][name] = ds.iterator()
|
||||
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def _scaling_policy_reserves_train_resources(cls) -> bool:
|
||||
"""True iff Ray Train V2's ScalingPolicy will register training resources
|
||||
with the AutoscalingCoordinator for this run.
|
||||
|
||||
"""
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
return is_v2_enabled()
|
||||
|
||||
@staticmethod
|
||||
def default_ingest_options() -> "ExecutionOptions":
|
||||
"""The default Ray Data options used for data ingest.
|
||||
|
||||
By default, configurations are carried over from what is already set
|
||||
in DataContext.
|
||||
"""
|
||||
from ray.data import ExecutionOptions
|
||||
from ray.data.context import DataContext
|
||||
|
||||
ctx = DataContext.get_current()
|
||||
return ExecutionOptions(
|
||||
resource_limits=ctx.execution_options.resource_limits,
|
||||
exclude_resources=ctx.execution_options.exclude_resources,
|
||||
preserve_order=ctx.execution_options.preserve_order,
|
||||
verbose_progress=ctx.execution_options.verbose_progress,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import ray.cloudpickle as ray_pickle
|
||||
from ray._common.utils import binary_to_hex, hex_to_binary
|
||||
from ray.train._checkpoint import Checkpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
|
||||
PREPROCESSOR_KEY = "preprocessor_pkl"
|
||||
|
||||
|
||||
class FrameworkCheckpoint(Checkpoint):
|
||||
"""A checkpoint to preserve the functionality of legacy
|
||||
framework-specific checkpoints.
|
||||
|
||||
Example:
|
||||
|
||||
>>> import tempfile
|
||||
>>> from ray.data.preprocessor import Preprocessor
|
||||
>>> checkpoint = FrameworkCheckpoint(tempfile.mkdtemp())
|
||||
>>> checkpoint.get_preprocessor() is None
|
||||
True
|
||||
>>> preprocessor = Preprocessor()
|
||||
>>> preprocessor._attr = 1234
|
||||
>>> checkpoint.set_preprocessor(preprocessor)
|
||||
>>> checkpoint.get_preprocessor()._attr
|
||||
1234
|
||||
"""
|
||||
|
||||
def get_preprocessor(self) -> Optional["Preprocessor"]:
|
||||
"""Return the preprocessor stored in the checkpoint.
|
||||
|
||||
.. warning::
|
||||
|
||||
The checkpoint path must point to a **trusted** source.
|
||||
The preprocessor is stored as a pickle blob inside the checkpoint
|
||||
metadata. Loading a checkpoint from an untrusted path (shared
|
||||
storage, downloaded artifact, checkpoint produced by a different
|
||||
party) is equivalent to executing arbitrary Python code. Never
|
||||
call this method on a checkpoint you do not fully control.
|
||||
|
||||
Returns:
|
||||
The preprocessor stored in the checkpoint, or ``None`` if no
|
||||
preprocessor was stored.
|
||||
"""
|
||||
metadata = self.get_metadata()
|
||||
preprocessor_bytes = metadata.get(PREPROCESSOR_KEY)
|
||||
if preprocessor_bytes is None:
|
||||
return None
|
||||
return ray_pickle.loads(hex_to_binary(preprocessor_bytes))
|
||||
|
||||
def set_preprocessor(self, preprocessor: "Preprocessor"):
|
||||
"""Store a preprocessor with the checkpoint."""
|
||||
self.update_metadata(
|
||||
{PREPROCESSOR_KEY: binary_to_hex(ray_pickle.dumps(preprocessor))}
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
from ray.train._internal.state.state_manager import TrainRunStateManager
|
||||
|
||||
try:
|
||||
import pydantic # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"pydantic isn't installed."
|
||||
"To install pydantic, please run 'pip install pydantic'"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TrainRunStateManager",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import Optional
|
||||
|
||||
from ray.core.generated.export_train_state_pb2 import (
|
||||
ExportTrainRunAttemptEventData as ProtoTrainRunAttempt,
|
||||
ExportTrainRunEventData as ProtoTrainRun,
|
||||
)
|
||||
from ray.train._internal.state.schema import (
|
||||
ActorStatusEnum,
|
||||
RunStatusEnum,
|
||||
TrainRunInfo,
|
||||
TrainWorkerInfo,
|
||||
)
|
||||
|
||||
TRAIN_SCHEMA_VERSION = 1
|
||||
RAY_TRAIN_VERSION = 1
|
||||
|
||||
# Status mapping dictionaries
|
||||
_ACTOR_STATUS_MAP = {
|
||||
ActorStatusEnum.ALIVE: ProtoTrainRunAttempt.ActorStatus.ALIVE,
|
||||
ActorStatusEnum.DEAD: ProtoTrainRunAttempt.ActorStatus.DEAD,
|
||||
}
|
||||
|
||||
_RUN_ATTEMPT_STATUS_MAP = {
|
||||
RunStatusEnum.STARTED: ProtoTrainRunAttempt.RunAttemptStatus.PENDING,
|
||||
RunStatusEnum.RUNNING: ProtoTrainRunAttempt.RunAttemptStatus.RUNNING,
|
||||
RunStatusEnum.FINISHED: ProtoTrainRunAttempt.RunAttemptStatus.FINISHED,
|
||||
RunStatusEnum.ERRORED: ProtoTrainRunAttempt.RunAttemptStatus.ERRORED,
|
||||
RunStatusEnum.ABORTED: ProtoTrainRunAttempt.RunAttemptStatus.ABORTED,
|
||||
}
|
||||
|
||||
_RUN_STATUS_MAP = {
|
||||
RunStatusEnum.STARTED: ProtoTrainRun.RunStatus.INITIALIZING,
|
||||
RunStatusEnum.RUNNING: ProtoTrainRun.RunStatus.RUNNING,
|
||||
RunStatusEnum.FINISHED: ProtoTrainRun.RunStatus.FINISHED,
|
||||
RunStatusEnum.ERRORED: ProtoTrainRun.RunStatus.ERRORED,
|
||||
RunStatusEnum.ABORTED: ProtoTrainRun.RunStatus.ABORTED,
|
||||
}
|
||||
|
||||
|
||||
def _ms_to_ns(ms: Optional[int]) -> Optional[int]:
|
||||
if ms is None:
|
||||
return None
|
||||
return ms * 1000000
|
||||
|
||||
|
||||
# Helper conversion functions
|
||||
def _to_proto_resources(resources: dict) -> ProtoTrainRunAttempt.TrainResources:
|
||||
"""Convert resources dictionary to protobuf TrainResources."""
|
||||
return ProtoTrainRunAttempt.TrainResources(resources=resources)
|
||||
|
||||
|
||||
def _to_proto_worker(worker: TrainWorkerInfo) -> ProtoTrainRunAttempt.TrainWorker:
|
||||
"""Convert TrainWorker to protobuf format."""
|
||||
proto_worker = ProtoTrainRunAttempt.TrainWorker(
|
||||
world_rank=worker.world_rank,
|
||||
local_rank=worker.local_rank,
|
||||
node_rank=worker.node_rank,
|
||||
actor_id=bytes.fromhex(worker.actor_id),
|
||||
node_id=bytes.fromhex(worker.node_id),
|
||||
node_ip=worker.node_ip,
|
||||
pid=worker.pid,
|
||||
gpu_ids=worker.gpu_ids,
|
||||
status=_ACTOR_STATUS_MAP[worker.status],
|
||||
resources=_to_proto_resources(worker.resources),
|
||||
)
|
||||
|
||||
return proto_worker
|
||||
|
||||
|
||||
# Main conversion functions
|
||||
def train_run_info_to_proto_run(run_info: TrainRunInfo) -> ProtoTrainRun:
|
||||
"""Convert TrainRunInfo to TrainRun protobuf format."""
|
||||
proto_run = ProtoTrainRun(
|
||||
schema_version=TRAIN_SCHEMA_VERSION,
|
||||
ray_train_version=RAY_TRAIN_VERSION,
|
||||
id=run_info.id,
|
||||
name=run_info.name,
|
||||
job_id=bytes.fromhex(run_info.job_id),
|
||||
controller_actor_id=bytes.fromhex(run_info.controller_actor_id),
|
||||
status=_RUN_STATUS_MAP[run_info.run_status],
|
||||
status_detail=run_info.status_detail,
|
||||
start_time_ns=_ms_to_ns(run_info.start_time_ms),
|
||||
end_time_ns=_ms_to_ns(run_info.end_time_ms),
|
||||
)
|
||||
|
||||
return proto_run
|
||||
|
||||
|
||||
def train_run_info_to_proto_attempt(run_info: TrainRunInfo) -> ProtoTrainRunAttempt:
|
||||
"""Convert TrainRunInfo to TrainRunAttempt protobuf format."""
|
||||
|
||||
proto_attempt = ProtoTrainRunAttempt(
|
||||
schema_version=TRAIN_SCHEMA_VERSION,
|
||||
ray_train_version=RAY_TRAIN_VERSION,
|
||||
run_id=run_info.id,
|
||||
attempt_id=run_info.id, # Same as run_id
|
||||
status=_RUN_ATTEMPT_STATUS_MAP[run_info.run_status],
|
||||
status_detail=run_info.status_detail,
|
||||
start_time_ns=_ms_to_ns(run_info.start_time_ms),
|
||||
end_time_ns=_ms_to_ns(run_info.end_time_ms),
|
||||
resources=[_to_proto_resources(r) for r in run_info.resources],
|
||||
workers=[_to_proto_worker(worker) for worker in run_info.workers],
|
||||
)
|
||||
|
||||
return proto_attempt
|
||||
@@ -0,0 +1,165 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ray._common.pydantic_compat import BaseModel, Field
|
||||
from ray.dashboard.modules.job.pydantic_models import JobDetails
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
MAX_ERROR_STACK_TRACE_LENGTH = 50000
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class RunStatusEnum(str, Enum):
|
||||
"""Enumeration for the status of a train run."""
|
||||
|
||||
# (Deprecated) Replaced by RUNNING.
|
||||
# The train run has started
|
||||
STARTED = "STARTED"
|
||||
# The train run is running
|
||||
RUNNING = "RUNNING"
|
||||
# The train run was terminated as expected
|
||||
FINISHED = "FINISHED"
|
||||
# The train run was terminated early due to errors in the training function
|
||||
ERRORED = "ERRORED"
|
||||
# The train run was terminated early due to system errors or controller errors
|
||||
ABORTED = "ABORTED"
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ActorStatusEnum(str, Enum):
|
||||
DEAD = "DEAD"
|
||||
ALIVE = "ALIVE"
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainWorkerInfo(BaseModel):
|
||||
"""Metadata of a Ray Train worker."""
|
||||
|
||||
actor_id: str = Field(description="Actor ID of the worker.")
|
||||
world_rank: int = Field(description="World rank of the worker.")
|
||||
local_rank: int = Field(description="Local rank of the worker.")
|
||||
node_rank: int = Field(description="Node rank of the worker.")
|
||||
node_id: str = Field(description="ID of the node that the worker is running on.")
|
||||
node_ip: str = Field(
|
||||
description="IP address of the node that the worker is running on."
|
||||
)
|
||||
pid: int = Field(description="Process ID of the worker.")
|
||||
gpu_ids: List[int] = Field(
|
||||
description="A list of GPU ids allocated to that worker."
|
||||
)
|
||||
status: ActorStatusEnum = Field(
|
||||
description="The status of the train worker actor. It can be ALIVE or DEAD."
|
||||
)
|
||||
resources: Dict[str, float] = Field(
|
||||
description="The resources allocated to the worker."
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class MemoryInfo(BaseModel):
|
||||
rss: int
|
||||
vms: int
|
||||
pfaults: Optional[int] = None
|
||||
pageins: Optional[int] = None
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ProcessStats(BaseModel):
|
||||
cpuPercent: float
|
||||
# total memory, free memory, memory used ratio
|
||||
mem: Optional[List[int]] = None
|
||||
memoryInfo: MemoryInfo
|
||||
|
||||
|
||||
class ProcessGPUUsage(BaseModel):
|
||||
# This gpu usage stats from a process
|
||||
pid: int
|
||||
gpuMemoryUsage: int
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class GPUStats(BaseModel):
|
||||
uuid: str
|
||||
index: int
|
||||
name: str
|
||||
utilizationGpu: Optional[float] = None
|
||||
memoryUsed: float
|
||||
memoryTotal: float
|
||||
processInfo: ProcessGPUUsage
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainWorkerInfoWithDetails(TrainWorkerInfo):
|
||||
"""Metadata of a Ray Train worker."""
|
||||
|
||||
processStats: Optional[ProcessStats] = Field(
|
||||
None, description="Process stats of the worker."
|
||||
)
|
||||
gpus: List[GPUStats] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"GPU stats of the worker. "
|
||||
"Only returns GPUs that are attached to the worker process."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainDatasetInfo(BaseModel):
|
||||
name: str = Field(
|
||||
description="The key of the dataset dict specified in Ray Train Trainer."
|
||||
)
|
||||
dataset_uuid: str = Field(description="The uuid of the dataset.")
|
||||
dataset_name: Optional[str] = Field(None, description="The name of the dataset.")
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainRunInfo(BaseModel):
|
||||
"""Metadata for a Ray Train run and information about its workers."""
|
||||
|
||||
name: str = Field(description="The name of the Train run.")
|
||||
id: str = Field(description="The unique identifier for each Train run.")
|
||||
job_id: str = Field(description="The Ray Job ID.")
|
||||
controller_actor_id: str = Field(description="Actor Id of the Train controller.")
|
||||
workers: List[TrainWorkerInfo] = Field(
|
||||
description="A List of Train workers sorted by global ranks."
|
||||
)
|
||||
datasets: List[TrainDatasetInfo] = Field(
|
||||
description="A List of dataset info for this Train run."
|
||||
)
|
||||
run_status: RunStatusEnum = Field(
|
||||
description="The current status of the train run. It can be one of the "
|
||||
"following: RUNNING, FINISHED, ERRORED, or ABORTED."
|
||||
)
|
||||
status_detail: str = Field(
|
||||
description="Detailed information about the current run status, "
|
||||
"such as error messages."
|
||||
)
|
||||
start_time_ms: int = Field(
|
||||
description="The UNIX timestamp of the start time of this Train run."
|
||||
)
|
||||
end_time_ms: Optional[int] = Field(
|
||||
None,
|
||||
description="The UNIX timestamp of the end time of this Train run. "
|
||||
"If null, the Train run has not ended yet.",
|
||||
)
|
||||
resources: List[Dict[str, float]] = Field(
|
||||
description="The resources allocated to the worker."
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainRunInfoWithDetails(TrainRunInfo):
|
||||
"""Metadata for a Ray Train run and information about its workers."""
|
||||
|
||||
workers: List[TrainWorkerInfoWithDetails] = Field(
|
||||
description="A List of Train workers sorted by global ranks."
|
||||
)
|
||||
job_details: Optional[JobDetails] = Field(
|
||||
None, description="Details of the job that started this Train run."
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class TrainRunsResponse(BaseModel):
|
||||
train_runs: List[TrainRunInfoWithDetails]
|
||||
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
import ray
|
||||
from ray._private.event.export_event_logger import (
|
||||
EventLogType,
|
||||
check_export_api_enabled,
|
||||
get_export_event_logger,
|
||||
)
|
||||
from ray.actor import ActorHandle
|
||||
from ray.train._internal.state.schema import TrainRunInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class TrainStateActor:
|
||||
def __init__(self):
|
||||
self._run_infos: Dict[str, TrainRunInfo] = {}
|
||||
|
||||
(
|
||||
self._export_logger,
|
||||
self._is_train_run_export_api_enabled,
|
||||
self._is_train_run_attempt_export_api_enabled,
|
||||
) = self._init_export_logger()
|
||||
|
||||
def register_train_run(self, run_info: TrainRunInfo) -> None:
|
||||
# Register a new train run.
|
||||
self._run_infos[run_info.id] = run_info
|
||||
|
||||
self._maybe_export_train_run(run_info)
|
||||
self._maybe_export_train_run_attempt(run_info)
|
||||
|
||||
def get_train_run(self, run_id: str) -> Optional[TrainRunInfo]:
|
||||
# Retrieve a registered run with its id
|
||||
return self._run_infos.get(run_id, None)
|
||||
|
||||
def get_all_train_runs(self) -> Dict[str, TrainRunInfo]:
|
||||
# Retrieve all registered train runs
|
||||
return self._run_infos
|
||||
|
||||
# ============================
|
||||
# Export API
|
||||
# ============================
|
||||
|
||||
def is_export_api_enabled(self) -> bool:
|
||||
return self._export_logger is not None
|
||||
|
||||
def _init_export_logger(self) -> tuple[Optional[logging.Logger], bool, bool]:
|
||||
"""Initialize the export logger and check if the export API is enabled.
|
||||
|
||||
Returns:
|
||||
A tuple containing:
|
||||
- The export logger (or None if export API is not enabled).
|
||||
- A boolean indicating if the export API is enabled for train runs.
|
||||
- A boolean indicating if the export API is enabled for train run attempts.
|
||||
"""
|
||||
# Proto schemas should be imported within the scope of TrainStateActor to
|
||||
# prevent serialization errors.
|
||||
from ray.core.generated.export_event_pb2 import ExportEvent
|
||||
|
||||
is_train_run_export_api_enabled = check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_TRAIN_RUN
|
||||
)
|
||||
is_train_run_attempt_export_api_enabled = check_export_api_enabled(
|
||||
ExportEvent.SourceType.EXPORT_TRAIN_RUN_ATTEMPT
|
||||
)
|
||||
export_api_enabled = (
|
||||
is_train_run_export_api_enabled or is_train_run_attempt_export_api_enabled
|
||||
)
|
||||
|
||||
if not export_api_enabled:
|
||||
return None, False, False
|
||||
|
||||
log_directory = os.path.join(
|
||||
ray._private.worker._global_node.get_session_dir_path(), "logs"
|
||||
)
|
||||
logger = None
|
||||
try:
|
||||
logger = get_export_event_logger(
|
||||
EventLogType.TRAIN_STATE,
|
||||
log_directory,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unable to initialize the export event logger, so no Train export "
|
||||
"events will be written."
|
||||
)
|
||||
|
||||
if logger is None:
|
||||
return None, False, False
|
||||
|
||||
return (
|
||||
logger,
|
||||
is_train_run_export_api_enabled,
|
||||
is_train_run_attempt_export_api_enabled,
|
||||
)
|
||||
|
||||
def _maybe_export_train_run(self, run_info: TrainRunInfo) -> None:
|
||||
if not self._is_train_run_export_api_enabled:
|
||||
return
|
||||
|
||||
from ray.train._internal.state.export import train_run_info_to_proto_run
|
||||
|
||||
run_proto = train_run_info_to_proto_run(run_info)
|
||||
self._export_logger.send_event(run_proto)
|
||||
|
||||
def _maybe_export_train_run_attempt(self, run_info: TrainRunInfo) -> None:
|
||||
if not self._is_train_run_attempt_export_api_enabled:
|
||||
return
|
||||
|
||||
from ray.train._internal.state.export import train_run_info_to_proto_attempt
|
||||
|
||||
run_attempt_proto = train_run_info_to_proto_attempt(run_info)
|
||||
self._export_logger.send_event(run_attempt_proto)
|
||||
|
||||
|
||||
TRAIN_STATE_ACTOR_NAME = "train_state_actor"
|
||||
TRAIN_STATE_ACTOR_NAMESPACE = "_train_state_actor"
|
||||
|
||||
_state_actor_lock: threading.RLock = threading.RLock()
|
||||
|
||||
|
||||
def get_or_create_state_actor() -> ActorHandle:
|
||||
"""Get or create a `TrainStateActor` on the head node."""
|
||||
with _state_actor_lock:
|
||||
state_actor = TrainStateActor.options(
|
||||
name=TRAIN_STATE_ACTOR_NAME,
|
||||
namespace=TRAIN_STATE_ACTOR_NAMESPACE,
|
||||
get_if_exists=True,
|
||||
lifetime="detached",
|
||||
resources={"node:__internal_head__": 0.001},
|
||||
# Escape from the parent's placement group
|
||||
scheduling_strategy="DEFAULT",
|
||||
).remote()
|
||||
|
||||
# Ensure the state actor is ready
|
||||
ray.get(state_actor.__ray_ready__.remote())
|
||||
return state_actor
|
||||
|
||||
|
||||
def get_state_actor() -> Optional[ActorHandle]:
|
||||
"""Get the `TrainStateActor` if exists, otherwise return None."""
|
||||
try:
|
||||
return ray.get_actor(
|
||||
name=TRAIN_STATE_ACTOR_NAME,
|
||||
namespace=TRAIN_STATE_ACTOR_NAMESPACE,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
|
||||
import ray
|
||||
from ray.train._internal.state.schema import (
|
||||
ActorStatusEnum,
|
||||
RunStatusEnum,
|
||||
TrainDatasetInfo,
|
||||
TrainRunInfo,
|
||||
TrainWorkerInfo,
|
||||
)
|
||||
from ray.train._internal.utils import check_for_failure
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import Dataset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrainRunStateManager:
|
||||
"""A class that aggregates and reports train run info to TrainStateActor.
|
||||
|
||||
This manager class is created on the train controller layer for each run.
|
||||
"""
|
||||
|
||||
def __init__(self, state_actor) -> None:
|
||||
self.state_actor = state_actor
|
||||
self.train_run_info_dict = defaultdict(dict)
|
||||
|
||||
def register_train_run(
|
||||
self,
|
||||
run_id: str,
|
||||
job_id: str,
|
||||
run_name: str,
|
||||
run_status: str,
|
||||
controller_actor_id: str,
|
||||
datasets: Dict[str, "Dataset"],
|
||||
worker_group: WorkerGroup,
|
||||
start_time_ms: float,
|
||||
resources: List[Dict[str, float]],
|
||||
status_detail: str = "",
|
||||
) -> None:
|
||||
"""Collect Train Run Info and report to StateActor."""
|
||||
|
||||
if not self.state_actor:
|
||||
logger.warning(
|
||||
"Unable to register train run since `TrainStateActor` is not started."
|
||||
)
|
||||
return
|
||||
|
||||
def collect_train_worker_info():
|
||||
train_context = ray.train.get_context()
|
||||
core_context = ray.runtime_context.get_runtime_context()
|
||||
return TrainWorkerInfo(
|
||||
world_rank=train_context.get_world_rank(),
|
||||
local_rank=train_context.get_local_rank(),
|
||||
node_rank=train_context.get_node_rank(),
|
||||
actor_id=core_context.get_actor_id(),
|
||||
node_id=core_context.get_node_id(),
|
||||
node_ip=ray.util.get_node_ip_address(),
|
||||
gpu_ids=ray.get_gpu_ids(),
|
||||
pid=os.getpid(),
|
||||
resources=resources[0],
|
||||
status=ActorStatusEnum.ALIVE,
|
||||
)
|
||||
|
||||
futures = [
|
||||
worker_group.execute_single_async(index, collect_train_worker_info)
|
||||
for index in range(len(worker_group))
|
||||
]
|
||||
success, exception = check_for_failure(futures)
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to collect run information from the Ray Train "
|
||||
f"workers:\n{exception}"
|
||||
)
|
||||
return
|
||||
|
||||
worker_info_list = ray.get(futures)
|
||||
worker_info_list = sorted(worker_info_list, key=lambda info: info.world_rank)
|
||||
|
||||
dataset_info_list = [
|
||||
TrainDatasetInfo(
|
||||
name=ds_name,
|
||||
dataset_name=ds._dataset_name,
|
||||
dataset_uuid=ds._uuid,
|
||||
)
|
||||
for ds_name, ds in datasets.items()
|
||||
]
|
||||
|
||||
updates = dict(
|
||||
id=run_id,
|
||||
job_id=job_id,
|
||||
name=run_name,
|
||||
controller_actor_id=controller_actor_id,
|
||||
workers=worker_info_list,
|
||||
datasets=dataset_info_list,
|
||||
start_time_ms=start_time_ms,
|
||||
run_status=run_status,
|
||||
status_detail=status_detail,
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
# Clear the cached info to avoid registering the same run twice
|
||||
self.train_run_info_dict[run_id] = {}
|
||||
self._update_train_run_info(run_id, updates)
|
||||
|
||||
def end_train_run(
|
||||
self,
|
||||
run_id: str,
|
||||
run_status: RunStatusEnum,
|
||||
status_detail: str,
|
||||
end_time_ms: int,
|
||||
):
|
||||
"""Update the train run status when the training is finished."""
|
||||
updates = dict(
|
||||
run_status=run_status,
|
||||
status_detail=status_detail,
|
||||
end_time_ms=end_time_ms,
|
||||
)
|
||||
self._update_train_run_info(run_id, updates)
|
||||
|
||||
def _update_train_run_info(self, run_id: str, updates: Dict[str, Any]) -> None:
|
||||
"""Update specific fields of a registered TrainRunInfo instance."""
|
||||
if run_id in self.train_run_info_dict:
|
||||
self.train_run_info_dict[run_id].update(updates)
|
||||
train_run_info = TrainRunInfo(**self.train_run_info_dict[run_id])
|
||||
ray.get(self.state_actor.register_train_run.remote(train_run_info))
|
||||
@@ -0,0 +1,736 @@
|
||||
# Try import ray[train] core requirements (defined in setup.py)
|
||||
# isort: off
|
||||
try:
|
||||
import fsspec # noqa
|
||||
from fsspec.implementations.local import LocalFileSystem
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
raise RuntimeError(
|
||||
"fsspec is a required dependency of Ray Train and Ray Tune. "
|
||||
"Please install with: `pip install fsspec`"
|
||||
) from e
|
||||
|
||||
try:
|
||||
import pyarrow
|
||||
import pyarrow.fs
|
||||
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
raise RuntimeError(
|
||||
"pyarrow is a required dependency of Ray Train and Ray Tune. "
|
||||
"Please install with: `pip install pyarrow`"
|
||||
) from e
|
||||
|
||||
try:
|
||||
# check if Arrow has S3 support
|
||||
from pyarrow.fs import S3FileSystem
|
||||
except ImportError:
|
||||
S3FileSystem = None
|
||||
# isort: on
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from ray.air._internal.filelock import TempFileLock
|
||||
from ray.train._internal.syncer import SyncConfig, Syncer, _BackgroundSyncer
|
||||
from ray.train.constants import _get_ray_train_session_dir
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train._checkpoint import Checkpoint
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_VALIDATE_STORAGE_MARKER_FILENAME = ".validate_storage_marker"
|
||||
|
||||
|
||||
class _ExcludingLocalFilesystem(LocalFileSystem):
|
||||
"""LocalFileSystem wrapper to exclude files according to patterns.
|
||||
|
||||
Args:
|
||||
root_path: Root path to strip when matching with the exclude pattern.
|
||||
Ex: root_path="/tmp/a/b/c", exclude=["*a*"], will exclude
|
||||
/tmp/a/b/c/_a_.txt but not ALL of /tmp/a/*.
|
||||
exclude: List of patterns that are applied to files returned by
|
||||
``self.find()``. If a file path matches this pattern, it will
|
||||
be excluded.
|
||||
**kwargs: Forwarded to the ``fsspec.implementations.local.LocalFileSystem``
|
||||
parent class.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, root_path: Path, exclude: List[str], **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._exclude = exclude
|
||||
self._root_path = root_path
|
||||
|
||||
@property
|
||||
def fsid(self):
|
||||
return "_excluding_local"
|
||||
|
||||
def _should_exclude(self, path: str) -> bool:
|
||||
"""Return True if `path` (relative to `root_path`) matches any of the
|
||||
`self._exclude` patterns."""
|
||||
path = Path(path)
|
||||
relative_path = path.relative_to(self._root_path).as_posix()
|
||||
match_candidates = [relative_path]
|
||||
if path.is_dir():
|
||||
# Everything is in posix path format ('/')
|
||||
match_candidates.append(relative_path + "/")
|
||||
|
||||
for excl in self._exclude:
|
||||
if any(fnmatch.fnmatch(candidate, excl) for candidate in match_candidates):
|
||||
return True
|
||||
return False
|
||||
|
||||
def find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
|
||||
"""Call parent find() and exclude from result."""
|
||||
paths = super().find(
|
||||
path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
|
||||
)
|
||||
if detail:
|
||||
return {
|
||||
path: out
|
||||
for path, out in paths.items()
|
||||
if not self._should_exclude(path)
|
||||
}
|
||||
else:
|
||||
return [path for path in paths if not self._should_exclude(path)]
|
||||
|
||||
|
||||
def _pyarrow_fs_copy_files(
|
||||
source, destination, source_filesystem=None, destination_filesystem=None, **kwargs
|
||||
):
|
||||
if S3FileSystem and isinstance(destination_filesystem, pyarrow.fs.S3FileSystem):
|
||||
# Workaround multi-threading issue with pyarrow. Note that use_threads=True
|
||||
# is safe for download, just not for uploads, see:
|
||||
# https://github.com/apache/arrow/issues/32372
|
||||
kwargs.setdefault("use_threads", False)
|
||||
|
||||
# Use a large chunk size to speed up large checkpoint transfers.
|
||||
kwargs.setdefault("chunk_size", 64 * 1024 * 1024)
|
||||
|
||||
return pyarrow.fs.copy_files(
|
||||
source,
|
||||
destination,
|
||||
source_filesystem=source_filesystem,
|
||||
destination_filesystem=destination_filesystem,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# TODO(justinvyu): Add unit tests for all these utils.
|
||||
|
||||
|
||||
def _delete_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str):
|
||||
is_dir = _is_directory(fs, fs_path)
|
||||
|
||||
try:
|
||||
if is_dir:
|
||||
fs.delete_dir(fs_path)
|
||||
else:
|
||||
fs.delete_file(fs_path)
|
||||
except Exception:
|
||||
logger.exception(f"Caught exception when deleting path at ({fs}, {fs_path}):")
|
||||
|
||||
|
||||
def _download_from_fs_path(
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
local_path: str,
|
||||
filelock: bool = True,
|
||||
):
|
||||
"""Downloads a directory or file from (fs, fs_path) to a local path.
|
||||
|
||||
If fs_path points to a directory:
|
||||
- The full directory contents are downloaded directly into `local_path`,
|
||||
rather than to a subdirectory of `local_path`.
|
||||
|
||||
If fs_path points to a file:
|
||||
- The file is downloaded to `local_path`, which is expected to be a file path.
|
||||
|
||||
If the download fails, the `local_path` contents are
|
||||
cleaned up before raising, if the directory did not previously exist.
|
||||
|
||||
NOTE: This method creates `local_path`'s parent directories if they do not
|
||||
already exist. If the download fails, this does NOT clean up all the parent
|
||||
directories that were created.
|
||||
|
||||
Args:
|
||||
fs: The filesystem to download from.
|
||||
fs_path: The filesystem path (either a directory or a file) to download.
|
||||
local_path: The local path to download to.
|
||||
filelock: Whether to require a file lock before downloading, useful for
|
||||
multiple downloads to the same directory that may be happening in parallel.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if (fs, fs_path) doesn't exist.
|
||||
"""
|
||||
|
||||
_local_path = Path(local_path).resolve()
|
||||
exists_before = _local_path.exists()
|
||||
if _is_directory(fs=fs, fs_path=fs_path):
|
||||
_local_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
_local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if filelock:
|
||||
with TempFileLock(f"{os.path.normpath(local_path)}.lock"):
|
||||
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
|
||||
else:
|
||||
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
|
||||
except Exception as e:
|
||||
# Clean up the directory if downloading was unsuccessful
|
||||
if not exists_before:
|
||||
shutil.rmtree(local_path, ignore_errors=True)
|
||||
raise e
|
||||
|
||||
|
||||
def _upload_to_fs_path(
|
||||
local_path: str,
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Uploads a local directory or file to (fs, fs_path).
|
||||
|
||||
NOTE: This will create all necessary parent directories at the destination.
|
||||
|
||||
Args:
|
||||
local_path: The local path to upload.
|
||||
fs: The filesystem to upload to.
|
||||
fs_path: The filesystem path where the dir/file will be uploaded to.
|
||||
exclude: A list of filename matches to exclude from upload. This includes
|
||||
all files under subdirectories as well.
|
||||
This pattern will match with the relative paths of all files under
|
||||
`local_path`.
|
||||
Ex: ["*.png"] to exclude all .png images.
|
||||
"""
|
||||
|
||||
if not exclude:
|
||||
# TODO(justinvyu): uploading a single file doesn't work
|
||||
# (since we always create a directory at fs_path)
|
||||
_create_directory(fs=fs, fs_path=fs_path)
|
||||
_pyarrow_fs_copy_files(local_path, fs_path, destination_filesystem=fs)
|
||||
return
|
||||
|
||||
_upload_to_uri_with_exclude_fsspec(
|
||||
local_path=local_path, fs=fs, fs_path=fs_path, exclude=exclude
|
||||
)
|
||||
|
||||
|
||||
def _upload_to_uri_with_exclude_fsspec(
|
||||
local_path: str, fs: "pyarrow.fs", fs_path: str, exclude: Optional[List[str]]
|
||||
) -> None:
|
||||
local_fs = _ExcludingLocalFilesystem(root_path=local_path, exclude=exclude)
|
||||
handler = pyarrow.fs.FSSpecHandler(local_fs)
|
||||
source_fs = pyarrow.fs.PyFileSystem(handler)
|
||||
|
||||
_create_directory(fs=fs, fs_path=fs_path)
|
||||
_pyarrow_fs_copy_files(
|
||||
local_path, fs_path, source_filesystem=source_fs, destination_filesystem=fs
|
||||
)
|
||||
|
||||
|
||||
def _list_at_fs_path(
|
||||
fs: pyarrow.fs.FileSystem,
|
||||
fs_path: str,
|
||||
file_filter: Optional[Callable[[pyarrow.fs.FileInfo], bool]] = None,
|
||||
) -> List[str]:
|
||||
"""Returns the list of filenames at (fs, fs_path), similar to os.listdir.
|
||||
|
||||
If the path doesn't exist, returns an empty list.
|
||||
"""
|
||||
if file_filter is None:
|
||||
file_filter = lambda x: True # noqa: E731
|
||||
|
||||
selector = pyarrow.fs.FileSelector(fs_path, allow_not_found=True, recursive=False)
|
||||
return [
|
||||
os.path.relpath(file_info.path.lstrip("/"), start=fs_path.lstrip("/"))
|
||||
for file_info in fs.get_file_info(selector)
|
||||
if file_filter(file_info)
|
||||
]
|
||||
|
||||
|
||||
def _exists_at_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
|
||||
"""Returns True if (fs, fs_path) exists."""
|
||||
|
||||
valid = fs.get_file_info(fs_path)
|
||||
return valid.type != pyarrow.fs.FileType.NotFound
|
||||
|
||||
|
||||
def _is_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
|
||||
"""Checks if (fs, fs_path) is a directory or a file.
|
||||
|
||||
Args:
|
||||
fs: The filesystem to use.
|
||||
fs_path: The path on the filesystem to check.
|
||||
|
||||
Returns:
|
||||
True if the path is a directory, False if it is a file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if (fs, fs_path) doesn't exist.
|
||||
"""
|
||||
|
||||
file_info = fs.get_file_info(fs_path)
|
||||
if file_info.type == pyarrow.fs.FileType.NotFound:
|
||||
raise FileNotFoundError(f"Path not found: ({fs}, {fs_path})")
|
||||
|
||||
return not file_info.is_file
|
||||
|
||||
|
||||
def _create_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> None:
|
||||
"""Create directory at (fs, fs_path).
|
||||
|
||||
Some external filesystems require directories to already exist, or at least
|
||||
the `netloc` to be created (e.g. PyArrows ``mock://`` filesystem).
|
||||
|
||||
Generally this should be done before and outside of Ray applications. This
|
||||
utility is thus primarily used in testing, e.g. of ``mock://` URIs.
|
||||
"""
|
||||
try:
|
||||
fs.create_dir(fs_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Caught exception when creating directory at ({fs}, {fs_path}):"
|
||||
)
|
||||
|
||||
|
||||
def get_fs_and_path(
|
||||
storage_path: Union[str, os.PathLike],
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
) -> Tuple[pyarrow.fs.FileSystem, str]:
|
||||
"""Returns the fs and path from a storage path and an optional custom fs.
|
||||
|
||||
Args:
|
||||
storage_path: A storage path or URI. (ex: s3://bucket/path or /tmp/ray_results)
|
||||
storage_filesystem: A custom filesystem to use. If not provided,
|
||||
this will be auto-resolved by pyarrow. If provided, the storage_path
|
||||
is assumed to be prefix-stripped already, and must be a valid path
|
||||
on the filesystem.
|
||||
|
||||
Returns:
|
||||
A tuple of (filesystem, path) resolved from the inputs.
|
||||
"""
|
||||
storage_path = str(storage_path)
|
||||
|
||||
if storage_filesystem:
|
||||
return storage_filesystem, storage_path
|
||||
|
||||
return pyarrow.fs.FileSystem.from_uri(storage_path)
|
||||
|
||||
|
||||
class _FilesystemSyncer(_BackgroundSyncer):
|
||||
"""Syncer between local filesystem and a `storage_filesystem`."""
|
||||
|
||||
def __init__(self, storage_filesystem: Optional["pyarrow.fs.FileSystem"], **kwargs):
|
||||
self.storage_filesystem = storage_filesystem
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _sync_up_command(
|
||||
self, local_path: str, uri: str, exclude: Optional[List] = None
|
||||
) -> Tuple[Callable, Dict]:
|
||||
# TODO(justinvyu): Defer this cleanup up as part of the
|
||||
# external-facing Syncer deprecation.
|
||||
fs_path = uri
|
||||
return (
|
||||
_upload_to_fs_path,
|
||||
dict(
|
||||
local_path=local_path,
|
||||
fs=self.storage_filesystem,
|
||||
fs_path=fs_path,
|
||||
exclude=exclude,
|
||||
),
|
||||
)
|
||||
|
||||
def _sync_down_command(self, uri: str, local_path: str) -> Tuple[Callable, Dict]:
|
||||
fs_path = uri
|
||||
return (
|
||||
_download_from_fs_path,
|
||||
dict(
|
||||
fs=self.storage_filesystem,
|
||||
fs_path=fs_path,
|
||||
local_path=local_path,
|
||||
),
|
||||
)
|
||||
|
||||
def _delete_command(self, uri: str) -> Tuple[Callable, Dict]:
|
||||
fs_path = uri
|
||||
return _delete_fs_path, dict(fs=self.storage_filesystem, fs_path=fs_path)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class StorageContext:
|
||||
"""Shared context that holds the source of truth for all paths and
|
||||
storage utilities, passed along from the driver to workers.
|
||||
|
||||
This object defines a few types of paths:
|
||||
1. *_fs_path: A path on the `storage_filesystem`. This is a regular path
|
||||
which has been prefix-stripped by pyarrow.fs.FileSystem.from_uri and
|
||||
can be joined with `Path(...).as_posix()`.
|
||||
2. *_driver_staging_path: The temporary staging directory on the local filesystem
|
||||
where driver artifacts are saved to before persisting them to storage.
|
||||
3. trial_working_directory: The local filesystem path that the remote
|
||||
actors' working directories are moved to by default.
|
||||
This is separated from the driver staging path so that driver syncing
|
||||
does not implicitly upload the trial working directory, for trials on the
|
||||
driver node.
|
||||
|
||||
Example with storage_path="mock:///bucket/path?param=1":
|
||||
|
||||
>>> import ray
|
||||
>>> from ray.train._internal.storage import StorageContext
|
||||
>>> import os
|
||||
>>> _ = ray.init()
|
||||
>>> storage = StorageContext(
|
||||
... storage_path="mock://netloc/bucket/path?param=1",
|
||||
... experiment_dir_name="exp_name",
|
||||
... )
|
||||
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
|
||||
<pyarrow._fs._MockFileSystem object...
|
||||
>>> storage.experiment_fs_path
|
||||
'bucket/path/exp_name'
|
||||
>>> storage.experiment_driver_staging_path # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts'
|
||||
>>> storage.trial_dir_name = "trial_dir"
|
||||
>>> storage.trial_fs_path
|
||||
'bucket/path/exp_name/trial_dir'
|
||||
>>> storage.trial_driver_staging_path # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts/trial_dir'
|
||||
>>> storage.trial_working_directory # doctest: +ELLIPSIS
|
||||
'/tmp/ray/session_.../artifacts/.../exp_name/working_dirs/trial_dir'
|
||||
>>> storage.current_checkpoint_index = 1
|
||||
>>> storage.checkpoint_fs_path
|
||||
'bucket/path/exp_name/trial_dir/checkpoint_000001'
|
||||
>>> ray.shutdown()
|
||||
|
||||
Example with storage_path="/tmp/ray_results":
|
||||
|
||||
>>> from ray.train._internal.storage import StorageContext
|
||||
>>> storage = StorageContext(
|
||||
... storage_path="/tmp/ray_results",
|
||||
... experiment_dir_name="exp_name",
|
||||
... )
|
||||
>>> storage.storage_fs_path
|
||||
'/tmp/ray_results'
|
||||
>>> storage.experiment_fs_path
|
||||
'/tmp/ray_results/exp_name'
|
||||
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
|
||||
<pyarrow._fs.LocalFileSystem object...
|
||||
|
||||
Internal Usage Examples:
|
||||
- To copy files to the trial directory on the storage filesystem:
|
||||
|
||||
pyarrow.fs.copy_files(
|
||||
local_dir,
|
||||
Path(storage.trial_fs_path, "subdir").as_posix(),
|
||||
destination_filesystem=storage.filesystem
|
||||
)
|
||||
|
||||
.. warning::
|
||||
This is an experimental developer API and is subject to change
|
||||
without notice between versions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_path: Union[str, os.PathLike],
|
||||
experiment_dir_name: str,
|
||||
sync_config: Optional[SyncConfig] = None,
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
trial_dir_name: Optional[str] = None,
|
||||
current_checkpoint_index: int = -1,
|
||||
):
|
||||
from ray.tune.utils import date_str
|
||||
|
||||
self.custom_fs_provided = storage_filesystem is not None
|
||||
|
||||
# Invariant: (`storage_filesystem`, `storage_path`) is the location where
|
||||
# *all* results can be accessed.
|
||||
self.experiment_dir_name = experiment_dir_name
|
||||
self.trial_dir_name = trial_dir_name
|
||||
self.current_checkpoint_index = current_checkpoint_index
|
||||
self.sync_config = sync_config or SyncConfig()
|
||||
|
||||
self.storage_filesystem, self.storage_fs_path = get_fs_and_path(
|
||||
storage_path, storage_filesystem
|
||||
)
|
||||
self.storage_fs_path = Path(self.storage_fs_path).as_posix()
|
||||
|
||||
self.syncer: Syncer = _FilesystemSyncer(
|
||||
storage_filesystem=self.storage_filesystem,
|
||||
sync_period=self.sync_config.sync_period,
|
||||
sync_timeout=self.sync_config.sync_timeout,
|
||||
)
|
||||
|
||||
self._create_validation_file()
|
||||
self._check_validation_file()
|
||||
|
||||
# Timestamp is used to create a unique session directory for the current
|
||||
# training job. This is used to avoid conflicts when multiple training jobs
|
||||
# run with the same name in the same cluster.
|
||||
# This is set ONCE at the creation of the storage context, on the driver.
|
||||
self._timestamp = date_str()
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"StorageContext<\n"
|
||||
f" storage_filesystem='{self.storage_filesystem.type_name}',\n"
|
||||
f" storage_fs_path='{self.storage_fs_path}',\n"
|
||||
f" experiment_dir_name='{self.experiment_dir_name}',\n"
|
||||
f" trial_dir_name='{self.trial_dir_name}',\n"
|
||||
f" current_checkpoint_index={self.current_checkpoint_index},\n"
|
||||
">"
|
||||
)
|
||||
|
||||
def _create_validation_file(self):
|
||||
"""On the creation of a storage context, create a validation file at the
|
||||
storage path to verify that the storage path can be written to.
|
||||
This validation file is also used to check whether the storage path is
|
||||
accessible by all nodes in the cluster."""
|
||||
valid_file = Path(
|
||||
self.experiment_fs_path, _VALIDATE_STORAGE_MARKER_FILENAME
|
||||
).as_posix()
|
||||
self.storage_filesystem.create_dir(self.experiment_fs_path)
|
||||
with self.storage_filesystem.open_output_stream(valid_file):
|
||||
pass
|
||||
|
||||
def _check_validation_file(self):
|
||||
"""Checks that the validation file exists at the storage path."""
|
||||
valid_file = Path(
|
||||
self.experiment_fs_path, _VALIDATE_STORAGE_MARKER_FILENAME
|
||||
).as_posix()
|
||||
if not _exists_at_fs_path(fs=self.storage_filesystem, fs_path=valid_file):
|
||||
raise RuntimeError(
|
||||
f"Unable to set up cluster storage with the following settings:\n{self}"
|
||||
"\nCheck that all nodes in the cluster have read/write access "
|
||||
"to the configured storage path. `RunConfig(storage_path)` should be "
|
||||
"set to a cloud storage URI or a shared filesystem path accessible "
|
||||
"by all nodes in your cluster ('s3://bucket' or '/mnt/nfs'). "
|
||||
"A local path on the head node is not accessible by worker nodes. "
|
||||
"See: https://docs.ray.io/en/latest/train/user-guides/persistent-storage.html" # noqa: E501
|
||||
)
|
||||
|
||||
def _update_checkpoint_index(self, metrics: Dict):
|
||||
# Per default, increase by 1. This can be overwritten to customize checkpoint
|
||||
# directories.
|
||||
self.current_checkpoint_index += 1
|
||||
|
||||
def persist_current_checkpoint(self, checkpoint: "Checkpoint") -> "Checkpoint":
|
||||
"""Persists a given checkpoint to the current checkpoint path on the filesystem.
|
||||
|
||||
"Current" is defined by the `current_checkpoint_index` attribute of the
|
||||
storage context.
|
||||
|
||||
This method copies the checkpoint files to the storage location.
|
||||
It's up to the user to delete the original checkpoint files if desired.
|
||||
|
||||
For example, the original directory is typically a local temp directory.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint to persist to (fs, checkpoint_fs_path).
|
||||
|
||||
Returns:
|
||||
Checkpoint: A Checkpoint pointing to the persisted checkpoint location.
|
||||
"""
|
||||
# TODO(justinvyu): Fix this cyclical import.
|
||||
|
||||
logger.debug(
|
||||
"Copying checkpoint files to storage path:\n"
|
||||
"({source_fs}, {source}) -> ({dest_fs}, {destination})".format(
|
||||
source=checkpoint.path,
|
||||
destination=self.checkpoint_fs_path,
|
||||
source_fs=checkpoint.filesystem,
|
||||
dest_fs=self.storage_filesystem,
|
||||
)
|
||||
)
|
||||
|
||||
# Raise an error if the storage path is not accessible when
|
||||
# attempting to upload a checkpoint from a remote worker.
|
||||
# Ex: If storage_path is a local path, then a validation marker
|
||||
# will only exist on the head node but not the worker nodes.
|
||||
self._check_validation_file()
|
||||
|
||||
self.storage_filesystem.create_dir(self.checkpoint_fs_path)
|
||||
_pyarrow_fs_copy_files(
|
||||
source=checkpoint.path,
|
||||
destination=self.checkpoint_fs_path,
|
||||
source_filesystem=checkpoint.filesystem,
|
||||
destination_filesystem=self.storage_filesystem,
|
||||
)
|
||||
|
||||
persisted_checkpoint = checkpoint.__class__(
|
||||
filesystem=self.storage_filesystem,
|
||||
path=self.checkpoint_fs_path,
|
||||
)
|
||||
logger.info(f"Checkpoint successfully created at: {persisted_checkpoint}")
|
||||
return persisted_checkpoint
|
||||
|
||||
def persist_artifacts(self, force: bool = False) -> None:
|
||||
"""Persists all artifacts within `trial_local_dir` to storage.
|
||||
|
||||
This method possibly launches a background task to sync the trial dir,
|
||||
depending on the `sync_period` + `sync_artifacts_on_checkpoint`
|
||||
settings of `SyncConfig`.
|
||||
|
||||
`(local_fs, trial_working_dir) -> (storage_filesystem, trial_fs_path)`
|
||||
|
||||
Args:
|
||||
force: If True, wait for a previous sync to finish, launch a new one,
|
||||
and wait for that one to finish. By the end of a `force=True` call, the
|
||||
latest version of the trial artifacts will be persisted.
|
||||
"""
|
||||
if not self.sync_config.sync_artifacts:
|
||||
return
|
||||
|
||||
# Skip if there are no artifacts to sync
|
||||
is_empty = not any(os.scandir(self.trial_working_directory))
|
||||
if is_empty:
|
||||
return
|
||||
|
||||
if force:
|
||||
self.syncer.wait()
|
||||
self.syncer.sync_up(
|
||||
local_dir=self.trial_working_directory, remote_dir=self.trial_fs_path
|
||||
)
|
||||
self.syncer.wait()
|
||||
else:
|
||||
self.syncer.sync_up_if_needed(
|
||||
local_dir=self.trial_working_directory, remote_dir=self.trial_fs_path
|
||||
)
|
||||
|
||||
@property
|
||||
def experiment_fs_path(self) -> str:
|
||||
"""The path on the `storage_filesystem` to the experiment directory.
|
||||
|
||||
NOTE: This does not have a URI prefix anymore, since it has been stripped
|
||||
by pyarrow.fs.FileSystem.from_uri already. The URI scheme information is
|
||||
kept in `storage_filesystem` instead.
|
||||
"""
|
||||
return Path(self.storage_fs_path, self.experiment_dir_name).as_posix()
|
||||
|
||||
def _get_session_path(self) -> str:
|
||||
"""The Ray Train/Tune session local directory used to stage files
|
||||
before persisting to the storage filesystem."""
|
||||
return Path(
|
||||
_get_ray_train_session_dir(), self._timestamp, self.experiment_dir_name
|
||||
).as_posix()
|
||||
|
||||
@property
|
||||
def experiment_driver_staging_path(self) -> str:
|
||||
"""The local filesystem path of the experiment directory on the driver node.
|
||||
|
||||
The driver is the node where `Trainer.fit`/`Tuner.fit` is being called.
|
||||
|
||||
This path is of the form:
|
||||
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
|
||||
<experiment_dir_name>/driver_artifacts`
|
||||
|
||||
This should be used as the temporary staging location for files *on the driver*
|
||||
before syncing them to `experiment_fs_path`.
|
||||
For example, the search algorithm should dump its state to this directory.
|
||||
See `trial_driver_staging_path` for writing trial-specific artifacts.
|
||||
|
||||
The directory is synced to
|
||||
`{storage_path}/{experiment_dir_name}` periodically.
|
||||
See `_ExperimentCheckpointManager.checkpoint` for where that happens.
|
||||
"""
|
||||
return Path(self._get_session_path(), "driver_artifacts").as_posix()
|
||||
|
||||
@property
|
||||
def trial_fs_path(self) -> str:
|
||||
"""The trial directory path on the `storage_filesystem`.
|
||||
|
||||
Raises a ValueError if `trial_dir_name` is not set beforehand.
|
||||
"""
|
||||
if self.trial_dir_name is None:
|
||||
raise RuntimeError(
|
||||
"Should not access `trial_fs_path` without setting `trial_dir_name`"
|
||||
)
|
||||
return Path(self.experiment_fs_path, self.trial_dir_name).as_posix()
|
||||
|
||||
@property
|
||||
def trial_driver_staging_path(self) -> str:
|
||||
"""The local filesystem path of the trial directory on the driver.
|
||||
|
||||
The driver is the node where `Trainer.fit`/`Tuner.fit` is being called.
|
||||
|
||||
This path is of the form:
|
||||
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
|
||||
<experiment_dir_name>/driver_artifacts/<trial_dir_name>`
|
||||
|
||||
This should be used as the temporary location for files on the driver
|
||||
before persisting them to `trial_fs_path`.
|
||||
|
||||
For example, callbacks (e.g., JsonLoggerCallback) should write trial-specific
|
||||
logfiles within this directory.
|
||||
"""
|
||||
if self.trial_dir_name is None:
|
||||
raise RuntimeError(
|
||||
"Should not access `trial_driver_staging_path` "
|
||||
"without setting `trial_dir_name`"
|
||||
)
|
||||
return Path(self.experiment_driver_staging_path, self.trial_dir_name).as_posix()
|
||||
|
||||
@property
|
||||
def trial_working_directory(self) -> str:
|
||||
"""The local filesystem path to trial working directory.
|
||||
|
||||
This path is of the form:
|
||||
`/tmp/ray/session_<session_id>/artifacts/<ray-train-job-timestamp>/
|
||||
<experiment_dir_name>/working_dirs/<trial_dir_name>`
|
||||
|
||||
Ray Train/Tune moves the remote actor's working directory to this path
|
||||
by default, unless disabled by `RAY_CHDIR_TO_TRIAL_DIR` environment variable.
|
||||
|
||||
Writing files to this directory allows users to persist training artifacts
|
||||
if `SyncConfig(sync_artifacts=True)` is set.
|
||||
"""
|
||||
if self.trial_dir_name is None:
|
||||
raise RuntimeError(
|
||||
"Cannot access `trial_working_directory` without "
|
||||
"setting `trial_dir_name`"
|
||||
)
|
||||
return Path(
|
||||
self._get_session_path(), "working_dirs", self.trial_dir_name
|
||||
).as_posix()
|
||||
|
||||
@property
|
||||
def checkpoint_fs_path(self) -> str:
|
||||
"""The current checkpoint directory path on the `storage_filesystem`.
|
||||
|
||||
"Current" refers to the checkpoint that is currently being created/persisted.
|
||||
The user of this class is responsible for setting the `current_checkpoint_index`
|
||||
(e.g., incrementing when needed).
|
||||
"""
|
||||
return Path(self.trial_fs_path, self.checkpoint_dir_name).as_posix()
|
||||
|
||||
@property
|
||||
def checkpoint_dir_name(self) -> str:
|
||||
"""The current checkpoint directory name, based on the checkpoint index."""
|
||||
return StorageContext._make_checkpoint_dir_name(self.current_checkpoint_index)
|
||||
|
||||
@staticmethod
|
||||
def get_experiment_dir_name(run_obj: Union[str, Callable, Type]) -> str:
|
||||
from ray.tune.experiment import Experiment
|
||||
from ray.tune.utils import date_str
|
||||
|
||||
run_identifier = Experiment.get_trainable_name(run_obj)
|
||||
|
||||
if bool(int(os.environ.get("TUNE_DISABLE_DATED_SUBDIR", 0))):
|
||||
dir_name = run_identifier
|
||||
else:
|
||||
dir_name = "{}_{}".format(run_identifier, date_str())
|
||||
return dir_name
|
||||
|
||||
@staticmethod
|
||||
def _make_checkpoint_dir_name(index: int):
|
||||
"""Get the name of the checkpoint directory, given an index."""
|
||||
return f"checkpoint_{index:06d}"
|
||||
@@ -0,0 +1,429 @@
|
||||
import abc
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ray._private.thirdparty.tabulate.tabulate import tabulate
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI
|
||||
from ray.widgets import Template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Syncing period for syncing checkpoints between nodes or to cloud.
|
||||
DEFAULT_SYNC_PERIOD = 300
|
||||
|
||||
# Default sync timeout after which syncing processes are aborted
|
||||
DEFAULT_SYNC_TIMEOUT = 1800
|
||||
|
||||
|
||||
@Deprecated
|
||||
@dataclass
|
||||
class SyncConfig:
|
||||
sync_period: int = DEFAULT_SYNC_PERIOD
|
||||
sync_timeout: int = DEFAULT_SYNC_TIMEOUT
|
||||
sync_artifacts: bool = False
|
||||
sync_artifacts_on_checkpoint: bool = True
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
"""Generate an HTML representation of the SyncConfig."""
|
||||
return Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
{
|
||||
"Setting": ["Sync period", "Sync timeout"],
|
||||
"Value": [self.sync_period, self.sync_timeout],
|
||||
},
|
||||
tablefmt="html",
|
||||
showindex=False,
|
||||
headers="keys",
|
||||
),
|
||||
max_height="none",
|
||||
)
|
||||
|
||||
|
||||
class _BackgroundProcess:
|
||||
def __init__(self, fn: Callable):
|
||||
self._fn = fn
|
||||
self._process = None
|
||||
self._result = {}
|
||||
self._start_time = float("-inf")
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
return self._process and self._process.is_alive()
|
||||
|
||||
@property
|
||||
def start_time(self):
|
||||
return self._start_time
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
self._result = {}
|
||||
|
||||
def entrypoint():
|
||||
try:
|
||||
result = self._fn(*args, **kwargs)
|
||||
except Exception as e:
|
||||
self._result["exception"] = e
|
||||
return
|
||||
|
||||
self._result["result"] = result
|
||||
|
||||
self._process = threading.Thread(target=entrypoint)
|
||||
self._process.daemon = True
|
||||
self._process.start()
|
||||
self._start_time = time.time()
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> Any:
|
||||
"""Waits for the background process to finish running. Waits until the
|
||||
background process has run for at least `timeout` seconds, counting from
|
||||
the time when the process was started."""
|
||||
if not self._process:
|
||||
return None
|
||||
|
||||
time_remaining = None
|
||||
if timeout:
|
||||
elapsed = time.time() - self.start_time
|
||||
time_remaining = max(timeout - elapsed, 0)
|
||||
|
||||
self._process.join(timeout=time_remaining)
|
||||
|
||||
if self._process.is_alive():
|
||||
self._process = None
|
||||
raise TimeoutError(
|
||||
f"{getattr(self._fn, '__name__', str(self._fn))} did not finish "
|
||||
f"running within the timeout of {timeout} seconds."
|
||||
)
|
||||
|
||||
self._process = None
|
||||
|
||||
exception = self._result.get("exception")
|
||||
if exception:
|
||||
raise exception
|
||||
|
||||
result = self._result.get("result")
|
||||
|
||||
self._result = {}
|
||||
return result
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class Syncer(abc.ABC):
|
||||
"""Syncer class for synchronizing data between Ray nodes and remote (cloud) storage.
|
||||
|
||||
This class handles data transfer for two cases:
|
||||
|
||||
1. Synchronizing data such as experiment state snapshots from the driver to
|
||||
cloud storage.
|
||||
2. Synchronizing data such as trial checkpoints from remote trainables to
|
||||
cloud storage.
|
||||
|
||||
Synchronizing tasks are usually asynchronous and can be awaited using ``wait()``.
|
||||
The base class implements a ``wait_or_retry()`` API that will retry a failed
|
||||
sync command.
|
||||
|
||||
The base class also exposes an API to only kick off syncs every ``sync_period``
|
||||
seconds.
|
||||
|
||||
Args:
|
||||
sync_period: The minimum time in seconds between sync operations, as
|
||||
used by ``sync_up/down_if_needed``.
|
||||
sync_timeout: The maximum time to wait for a sync process to finish before
|
||||
issuing a new sync operation. Ex: should be used by ``wait`` if launching
|
||||
asynchronous sync tasks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sync_period: float = DEFAULT_SYNC_PERIOD,
|
||||
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
|
||||
):
|
||||
self.sync_period = sync_period
|
||||
self.sync_timeout = sync_timeout
|
||||
self.last_sync_up_time = float("-inf")
|
||||
self.last_sync_down_time = float("-inf")
|
||||
|
||||
@abc.abstractmethod
|
||||
def sync_up(
|
||||
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
|
||||
) -> bool:
|
||||
"""Synchronize local directory to remote directory.
|
||||
|
||||
This function can spawn an asynchronous process that can be awaited in
|
||||
``wait()``.
|
||||
|
||||
Args:
|
||||
local_dir: Local directory to sync from.
|
||||
remote_dir: Remote directory to sync up to. This is an URI
|
||||
(``protocol://remote/path``).
|
||||
exclude: Pattern of files to exclude, e.g.
|
||||
``["*/checkpoint_*]`` to exclude trial checkpoints.
|
||||
|
||||
Returns:
|
||||
True if sync process has been spawned, False otherwise.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def sync_down(
|
||||
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
|
||||
) -> bool:
|
||||
"""Synchronize remote directory to local directory.
|
||||
|
||||
This function can spawn an asynchronous process that can be awaited in
|
||||
``wait()``.
|
||||
|
||||
Args:
|
||||
remote_dir: Remote directory to sync down from. This is an URI
|
||||
(``protocol://remote/path``).
|
||||
local_dir: Local directory to sync to.
|
||||
exclude: Pattern of files to exclude, e.g.
|
||||
``["*/checkpoint_*]`` to exclude trial checkpoints.
|
||||
|
||||
Returns:
|
||||
True if sync process has been spawned, False otherwise.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete(self, remote_dir: str) -> bool:
|
||||
"""Delete directory on remote storage.
|
||||
|
||||
This function can spawn an asynchronous process that can be awaited in
|
||||
``wait()``.
|
||||
|
||||
Args:
|
||||
remote_dir: Remote directory to delete. This is an URI
|
||||
(``protocol://remote/path``).
|
||||
|
||||
Returns:
|
||||
True if sync process has been spawned, False otherwise.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def retry(self):
|
||||
"""Retry the last sync up, sync down, or delete command.
|
||||
|
||||
You should implement this method if you spawn asynchronous syncing
|
||||
processes.
|
||||
"""
|
||||
pass
|
||||
|
||||
def wait(self, timeout: Optional[float] = None):
|
||||
"""Wait for asynchronous sync command to finish.
|
||||
|
||||
You should implement this method if you spawn asynchronous syncing
|
||||
processes. This method should timeout after the asynchronous command
|
||||
has run for `sync_timeout` seconds and raise a `TimeoutError`.
|
||||
"""
|
||||
pass
|
||||
|
||||
def sync_up_if_needed(
|
||||
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
|
||||
) -> bool:
|
||||
"""Syncs up if time since last sync up is greater than sync_period.
|
||||
|
||||
Args:
|
||||
local_dir: Local directory to sync from.
|
||||
remote_dir: Remote directory to sync up to. This is an URI
|
||||
(``protocol://remote/path``).
|
||||
exclude: Pattern of files to exclude, e.g.
|
||||
``["*/checkpoint_*]`` to exclude trial checkpoints.
|
||||
|
||||
Returns:
|
||||
The result of ``sync_up`` if a sync was triggered, otherwise ``None``.
|
||||
"""
|
||||
now = time.time()
|
||||
if now - self.last_sync_up_time >= self.sync_period:
|
||||
result = self.sync_up(
|
||||
local_dir=local_dir, remote_dir=remote_dir, exclude=exclude
|
||||
)
|
||||
self.last_sync_up_time = now
|
||||
return result
|
||||
|
||||
def sync_down_if_needed(
|
||||
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
|
||||
):
|
||||
"""Syncs down if time since last sync down is greater than sync_period.
|
||||
|
||||
Args:
|
||||
remote_dir: Remote directory to sync down from. This is an URI
|
||||
(``protocol://remote/path``).
|
||||
local_dir: Local directory to sync to.
|
||||
exclude: Pattern of files to exclude, e.g.
|
||||
``["*/checkpoint_*]`` to exclude trial checkpoints.
|
||||
|
||||
Returns:
|
||||
The result of ``sync_down`` if a sync was triggered, otherwise ``None``.
|
||||
"""
|
||||
now = time.time()
|
||||
if now - self.last_sync_down_time >= self.sync_period:
|
||||
result = self.sync_down(
|
||||
remote_dir=remote_dir, local_dir=local_dir, exclude=exclude
|
||||
)
|
||||
self.last_sync_down_time = now
|
||||
return result
|
||||
|
||||
def wait_or_retry(self, max_retries: int = 2, backoff_s: int = 5):
|
||||
assert max_retries > 0
|
||||
last_error_traceback = None
|
||||
for i in range(max_retries + 1):
|
||||
try:
|
||||
self.wait()
|
||||
except Exception as e:
|
||||
attempts_remaining = max_retries - i
|
||||
|
||||
# If we're out of retries, then save the full traceback of the last
|
||||
# error and show it when raising an exception.
|
||||
if attempts_remaining == 0:
|
||||
last_error_traceback = traceback.format_exc()
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The latest sync operation failed with the following error: "
|
||||
f"{repr(e)}\n"
|
||||
f"Retrying {attempts_remaining} more time(s) after sleeping "
|
||||
f"for {backoff_s} seconds..."
|
||||
)
|
||||
time.sleep(backoff_s)
|
||||
self.retry()
|
||||
continue
|
||||
# Succeeded!
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Failed sync even after {max_retries} retries. "
|
||||
f"The latest sync failed with the following error:\n{last_error_traceback}"
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
self.last_sync_up_time = float("-inf")
|
||||
self.last_sync_down_time = float("-inf")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return
|
||||
|
||||
|
||||
class _BackgroundSyncer(Syncer):
|
||||
"""Syncer using a background process for asynchronous file transfer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sync_period: float = DEFAULT_SYNC_PERIOD,
|
||||
sync_timeout: float = DEFAULT_SYNC_TIMEOUT,
|
||||
):
|
||||
super(_BackgroundSyncer, self).__init__(
|
||||
sync_period=sync_period, sync_timeout=sync_timeout
|
||||
)
|
||||
self._sync_process = None
|
||||
self._current_cmd = None
|
||||
|
||||
def _should_continue_existing_sync(self):
|
||||
"""Returns whether a previous sync is still running within the timeout."""
|
||||
return (
|
||||
self._sync_process
|
||||
and self._sync_process.is_running
|
||||
and time.time() - self._sync_process.start_time < self.sync_timeout
|
||||
)
|
||||
|
||||
def _launch_sync_process(self, sync_command: Tuple[Callable, Dict]):
|
||||
"""Waits for the previous sync process to finish,
|
||||
then launches a new process that runs the given command."""
|
||||
if self._sync_process:
|
||||
try:
|
||||
self.wait()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Last sync command failed with the following error:\n"
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
self._current_cmd = sync_command
|
||||
self.retry()
|
||||
|
||||
def sync_up(
|
||||
self, local_dir: str, remote_dir: str, exclude: Optional[List] = None
|
||||
) -> bool:
|
||||
if self._should_continue_existing_sync():
|
||||
logger.debug(
|
||||
f"Last sync still in progress, "
|
||||
f"skipping sync up of {local_dir} to {remote_dir}"
|
||||
)
|
||||
return False
|
||||
|
||||
sync_up_cmd = self._sync_up_command(
|
||||
local_path=local_dir, uri=remote_dir, exclude=exclude
|
||||
)
|
||||
self._launch_sync_process(sync_up_cmd)
|
||||
|
||||
return True
|
||||
|
||||
def _sync_up_command(
|
||||
self, local_path: str, uri: str, exclude: Optional[List] = None
|
||||
) -> Tuple[Callable, Dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_down(
|
||||
self, remote_dir: str, local_dir: str, exclude: Optional[List] = None
|
||||
) -> bool:
|
||||
if self._should_continue_existing_sync():
|
||||
logger.warning(
|
||||
f"Last sync still in progress, "
|
||||
f"skipping sync down of {remote_dir} to {local_dir}"
|
||||
)
|
||||
return False
|
||||
|
||||
sync_down_cmd = self._sync_down_command(uri=remote_dir, local_path=local_dir)
|
||||
self._launch_sync_process(sync_down_cmd)
|
||||
|
||||
return True
|
||||
|
||||
def _sync_down_command(self, uri: str, local_path: str) -> Tuple[Callable, Dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, remote_dir: str) -> bool:
|
||||
if self._should_continue_existing_sync():
|
||||
logger.warning(
|
||||
f"Last sync still in progress, skipping deletion of {remote_dir}"
|
||||
)
|
||||
return False
|
||||
|
||||
delete_cmd = self._delete_command(uri=remote_dir)
|
||||
self._launch_sync_process(delete_cmd)
|
||||
|
||||
return True
|
||||
|
||||
def _delete_command(self, uri: str) -> Tuple[Callable, Dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
def wait(self, timeout: Optional[float] = None):
|
||||
if self._sync_process:
|
||||
try:
|
||||
self._sync_process.wait(timeout=timeout or self.sync_timeout)
|
||||
except Exception as e:
|
||||
raise e
|
||||
finally:
|
||||
# Regardless of whether the sync process succeeded within the timeout,
|
||||
# clear the sync process so a new one can be created.
|
||||
self._sync_process = None
|
||||
|
||||
def retry(self):
|
||||
if not self._current_cmd:
|
||||
raise RuntimeError("No sync command set, cannot retry.")
|
||||
cmd, kwargs = self._current_cmd
|
||||
self._sync_process = _BackgroundProcess(cmd)
|
||||
self._sync_process.start(**kwargs)
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state["_sync_process"] = None
|
||||
return state
|
||||
@@ -0,0 +1,230 @@
|
||||
import abc
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ContextManager,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
import ray
|
||||
from ray._common.network_utils import find_free_port, is_ipv6
|
||||
from ray.actor import ActorHandle
|
||||
from ray.air._internal.util import (
|
||||
StartTraceback,
|
||||
StartTracebackWithWorkerRank,
|
||||
)
|
||||
from ray.exceptions import RayActorError
|
||||
from ray.types import ObjectRef
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_for_failure(
|
||||
remote_values: List[ObjectRef],
|
||||
) -> Tuple[bool, Optional[Exception]]:
|
||||
"""Check for actor failure when retrieving the remote values.
|
||||
|
||||
Args:
|
||||
remote_values: List of object references from Ray actor methods.
|
||||
|
||||
Returns:
|
||||
A tuple of (bool, Exception). The bool is
|
||||
True if evaluating all object references is successful, False otherwise.
|
||||
"""
|
||||
unfinished = remote_values.copy()
|
||||
|
||||
while len(unfinished) > 0:
|
||||
finished, unfinished = ray.wait(unfinished)
|
||||
|
||||
# If a failure occurs the ObjectRef will be marked as finished.
|
||||
# Calling ray.get will expose the failure as a RayActorError.
|
||||
for object_ref in finished:
|
||||
# Everything in finished has either failed or completed
|
||||
# successfully.
|
||||
try:
|
||||
ray.get(object_ref)
|
||||
except RayActorError as exc:
|
||||
failed_actor_rank = remote_values.index(object_ref)
|
||||
logger.info(f"Worker {failed_actor_rank} has failed.")
|
||||
return False, exc
|
||||
except Exception as exc:
|
||||
# Other (e.g. training) errors should be directly raised
|
||||
failed_worker_rank = remote_values.index(object_ref)
|
||||
raise StartTracebackWithWorkerRank(
|
||||
worker_rank=failed_worker_rank
|
||||
) from exc
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def get_address_and_port() -> Tuple[str, int]:
|
||||
"""Returns the IP address and a free port on this node."""
|
||||
addr = ray.util.get_node_ip_address()
|
||||
port = find_free_port(socket.AF_INET6 if is_ipv6(addr) else socket.AF_INET)
|
||||
return addr, port
|
||||
|
||||
|
||||
def update_env_vars(env_vars: Dict[str, Any]):
|
||||
"""Updates the environment variables on this worker process.
|
||||
|
||||
Args:
|
||||
env_vars: Environment variables to set.
|
||||
"""
|
||||
sanitized = {k: str(v) for k, v in env_vars.items()}
|
||||
os.environ.update(sanitized)
|
||||
|
||||
|
||||
def count_required_parameters(fn: Callable) -> int:
|
||||
"""Counts the number of required parameters of a function.
|
||||
|
||||
NOTE: *args counts as 1 required parameter.
|
||||
|
||||
Args:
|
||||
fn: The function whose required parameters should be counted.
|
||||
|
||||
Returns:
|
||||
The number of required parameters of ``fn``.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> def fn(a, b, /, c, *args, d=1, e=2, **kwargs):
|
||||
... pass
|
||||
>>> count_required_parameters(fn)
|
||||
4
|
||||
|
||||
>>> fn = lambda: 1
|
||||
>>> count_required_parameters(fn)
|
||||
0
|
||||
|
||||
>>> def fn(config, a, b=1, c=2):
|
||||
... pass
|
||||
>>> from functools import partial
|
||||
>>> count_required_parameters(partial(fn, a=0))
|
||||
1
|
||||
"""
|
||||
params = inspect.signature(fn).parameters.values()
|
||||
|
||||
positional_param_kinds = {
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.VAR_POSITIONAL,
|
||||
}
|
||||
return len(
|
||||
[
|
||||
p
|
||||
for p in params
|
||||
if p.default == inspect.Parameter.empty and p.kind in positional_param_kinds
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def construct_train_func(
|
||||
train_func: Union[Callable[[], T], Callable[[Dict[str, Any]], T]],
|
||||
config: Optional[Dict[str, Any]],
|
||||
train_func_context: ContextManager,
|
||||
fn_arg_name: Optional[str] = "train_func",
|
||||
discard_returns: bool = False,
|
||||
) -> Callable[[], T]:
|
||||
"""Validates and constructs the training function to execute.
|
||||
|
||||
Args:
|
||||
train_func: The training function to execute.
|
||||
This can either take in no arguments or a ``config`` dict.
|
||||
config: Configurations to pass into ``train_func``. If None then an empty
|
||||
Dict will be created.
|
||||
train_func_context: Context manager for user's `train_func`, which executes
|
||||
backend-specific logic before and after the training function.
|
||||
fn_arg_name: The name of training function to use for error messages.
|
||||
discard_returns: Whether to discard any returns from train_func or not.
|
||||
|
||||
Returns:
|
||||
A valid training function.
|
||||
|
||||
Raises:
|
||||
ValueError: if the input ``train_func`` is invalid.
|
||||
"""
|
||||
num_required_params = count_required_parameters(train_func)
|
||||
|
||||
if discard_returns:
|
||||
# Discard any returns from the function so that
|
||||
# BackendExecutor doesn't try to deserialize them.
|
||||
# Those returns are inaccesible with AIR anyway.
|
||||
@functools.wraps(train_func)
|
||||
def discard_return_wrapper(*args, **kwargs):
|
||||
try:
|
||||
train_func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
raise StartTraceback from e
|
||||
|
||||
wrapped_train_func = discard_return_wrapper
|
||||
else:
|
||||
wrapped_train_func = train_func
|
||||
|
||||
if num_required_params > 1:
|
||||
err_msg = (
|
||||
f"{fn_arg_name} should take in 0 or 1 required arguments, but it accepts "
|
||||
f"{num_required_params} required arguments instead."
|
||||
)
|
||||
raise ValueError(err_msg)
|
||||
elif num_required_params == 1:
|
||||
config = {} if config is None else config
|
||||
|
||||
@functools.wraps(wrapped_train_func)
|
||||
def train_fn():
|
||||
try:
|
||||
with train_func_context():
|
||||
return wrapped_train_func(config)
|
||||
except Exception as e:
|
||||
raise StartTraceback from e
|
||||
|
||||
else: # num_params == 0
|
||||
|
||||
@functools.wraps(wrapped_train_func)
|
||||
def train_fn():
|
||||
try:
|
||||
with train_func_context():
|
||||
return wrapped_train_func()
|
||||
except Exception as e:
|
||||
raise StartTraceback from e
|
||||
|
||||
return train_fn
|
||||
|
||||
|
||||
class Singleton(abc.ABCMeta):
|
||||
"""Singleton Abstract Base Class
|
||||
|
||||
https://stackoverflow.com/questions/33364070/implementing
|
||||
-singleton-as-metaclass-but-for-abstract-classes
|
||||
"""
|
||||
|
||||
_instances = {}
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
class ActorWrapper:
|
||||
"""Wraps an actor to provide same API as using the base class directly."""
|
||||
|
||||
def __init__(self, actor: ActorHandle):
|
||||
self.actor = actor
|
||||
|
||||
def __getattr__(self, item):
|
||||
# The below will fail if trying to access an attribute (not a method) from the
|
||||
# actor.
|
||||
actor_method = getattr(self.actor, item)
|
||||
return lambda *args, **kwargs: ray.get(actor_method.remote(*args, **kwargs))
|
||||
@@ -0,0 +1,445 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.air._internal.util import exception_cause, skip_exceptions
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.types import ObjectRef
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RayTrainWorker:
|
||||
"""A class to execute arbitrary functions. Does not hold any state."""
|
||||
|
||||
def __execute(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""Executes the input function and returns the output.
|
||||
|
||||
Args:
|
||||
func: The function to execute.
|
||||
*args: Positional arguments to pass into ``func``.
|
||||
**kwargs: Keyword arguments to pass into ``func``.
|
||||
|
||||
Returns:
|
||||
The result of calling ``func`` with the provided arguments.
|
||||
"""
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
skipped = skip_exceptions(e)
|
||||
raise skipped from exception_cause(skipped)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerMetadata:
|
||||
"""Metadata for each worker/actor.
|
||||
|
||||
This information is expected to stay the same throughout the lifetime of
|
||||
actor.
|
||||
|
||||
Args:
|
||||
node_id: ID of the node this worker is on.
|
||||
node_ip: IP address of the node this worker is on.
|
||||
hostname: Hostname that this worker is on.
|
||||
resource_ids: Map of accelerator resources
|
||||
("GPU", "neuron_cores", ..) to their IDs.
|
||||
pid: Process ID of this worker.
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
node_ip: str
|
||||
hostname: str
|
||||
resource_ids: Dict[str, List[str]]
|
||||
pid: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Worker:
|
||||
"""Class representing a Worker."""
|
||||
|
||||
actor: ActorHandle
|
||||
metadata: WorkerMetadata
|
||||
|
||||
|
||||
def create_executable_class(executable_cls: Optional[Type] = None) -> Type:
|
||||
"""Create the executable class to use as the Ray actors."""
|
||||
if not executable_cls:
|
||||
return RayTrainWorker
|
||||
elif issubclass(executable_cls, RayTrainWorker):
|
||||
return executable_cls
|
||||
else:
|
||||
|
||||
class _WrappedExecutable(executable_cls, RayTrainWorker):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
return _WrappedExecutable
|
||||
|
||||
|
||||
def construct_metadata() -> WorkerMetadata:
|
||||
"""Creates metadata for this worker.
|
||||
|
||||
This function is expected to be run on the actor.
|
||||
"""
|
||||
node_id = ray.get_runtime_context().get_node_id()
|
||||
node_ip = ray.util.get_node_ip_address()
|
||||
hostname = socket.gethostname()
|
||||
accelerator_ids = ray.get_runtime_context().get_accelerator_ids()
|
||||
pid = os.getpid()
|
||||
|
||||
return WorkerMetadata(
|
||||
node_id=node_id,
|
||||
node_ip=node_ip,
|
||||
hostname=hostname,
|
||||
resource_ids=accelerator_ids,
|
||||
pid=pid,
|
||||
)
|
||||
|
||||
|
||||
class WorkerGroup(BaseWorkerGroup):
|
||||
"""Group of Ray Actors that can execute arbitrary functions.
|
||||
|
||||
``WorkerGroup`` launches Ray actors according to the given
|
||||
specification. It can then execute arbitrary Python functions in each of
|
||||
these workers.
|
||||
|
||||
If not enough resources are available to launch the actors, the Ray
|
||||
cluster will automatically scale up if autoscaling is enabled.
|
||||
|
||||
Args:
|
||||
num_workers: The number of workers (Ray actors) to launch.
|
||||
Defaults to 1.
|
||||
resources_per_worker: Dictionary specifying the resources that will be
|
||||
requested for each worker. Defaults to {"CPU": 1}.
|
||||
actor_cls: If specified use this class as the remote actors.
|
||||
actor_cls_args: If ``actor_cls`` is provided, these positional args will
|
||||
be used for the worker initialization.
|
||||
actor_cls_kwargs: If ``actor_cls`` is provided, these keyword args will
|
||||
be used for the worker initialization.
|
||||
placement_group: The placement group that workers
|
||||
should be created in. Defaults to "default" which will inherit the
|
||||
parent placement group (if child tasks should be captured).
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
.. code_block:: python
|
||||
|
||||
worker_group = WorkerGroup(num_workers=2)
|
||||
output = worker_group.execute(lambda: 1)
|
||||
assert len(output) == 2
|
||||
assert all(o == 1 for o in output)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_workers: int = 1,
|
||||
resources_per_worker: Optional[Dict[str, float]] = None,
|
||||
actor_cls: Type = None,
|
||||
actor_cls_args: Optional[Tuple] = None,
|
||||
actor_cls_kwargs: Optional[Dict] = None,
|
||||
placement_group: Union[PlacementGroup, str] = "default",
|
||||
):
|
||||
if resources_per_worker is None:
|
||||
resources_per_worker = {"CPU": 1}
|
||||
else:
|
||||
resources_per_worker = resources_per_worker.copy()
|
||||
|
||||
if num_workers <= 0:
|
||||
raise ValueError(
|
||||
"The provided `num_workers` must be greater "
|
||||
f"than 0. Received num_workers={num_workers} "
|
||||
f"instead."
|
||||
)
|
||||
|
||||
if any(v < 0 for v in resources_per_worker.values()):
|
||||
raise ValueError(
|
||||
"The number of resources per worker must not be negative. "
|
||||
f"Received resources_per_worker={resources_per_worker}."
|
||||
)
|
||||
|
||||
if (actor_cls_args or actor_cls_kwargs) and not actor_cls:
|
||||
raise ValueError(
|
||||
"`actor_cls_args` or `actor_class_kwargs` are "
|
||||
"passed in but no `actor_cls` is passed in."
|
||||
)
|
||||
|
||||
self.num_workers = num_workers
|
||||
self.resources_per_worker = resources_per_worker
|
||||
|
||||
_resources_per_worker = copy.deepcopy(resources_per_worker)
|
||||
self.num_cpus_per_worker = _resources_per_worker.pop("CPU", 0)
|
||||
self.num_gpus_per_worker = _resources_per_worker.pop("GPU", 0)
|
||||
self.memory_per_worker = _resources_per_worker.pop("memory", 0)
|
||||
self.workers = []
|
||||
self._base_cls = create_executable_class(actor_cls)
|
||||
assert issubclass(self._base_cls, RayTrainWorker)
|
||||
|
||||
self._actor_cls_args = actor_cls_args or []
|
||||
self._actor_cls_kwargs = actor_cls_kwargs or {}
|
||||
|
||||
self._placement_group = placement_group
|
||||
|
||||
# TODO(matt): Validate resources. Fast-fail if it is impossible to
|
||||
# handle the request, rather than hang indefinitely.
|
||||
self._remote_cls = ray.remote(
|
||||
num_cpus=self.num_cpus_per_worker,
|
||||
num_gpus=self.num_gpus_per_worker,
|
||||
memory=self.memory_per_worker,
|
||||
resources=_resources_per_worker,
|
||||
)(self._base_cls)
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""Starts all the workers in this worker group."""
|
||||
if self.workers and len(self.workers) > 0:
|
||||
raise RuntimeError(
|
||||
"The workers have already been started. "
|
||||
"Please call `shutdown` first if you want to "
|
||||
"restart them."
|
||||
)
|
||||
|
||||
logger.debug(f"Starting {self.num_workers} workers.")
|
||||
self.add_workers(self.num_workers)
|
||||
logger.debug(f"{len(self.workers)} workers have successfully started.")
|
||||
|
||||
def shutdown(self, patience_s: float = 5):
|
||||
"""Shutdown all the workers in this worker group.
|
||||
|
||||
Args:
|
||||
patience_s: Attempt a graceful shutdown
|
||||
of the workers for this many seconds. Fallback to force kill
|
||||
if graceful shutdown is not complete after this time. If
|
||||
this is less than or equal to 0, immediately force kill all
|
||||
workers.
|
||||
"""
|
||||
logger.debug(f"Shutting down {len(self.workers)} workers.")
|
||||
if patience_s <= 0:
|
||||
for worker in self.workers:
|
||||
ray.kill(worker.actor)
|
||||
else:
|
||||
done_refs = [w.actor.__ray_terminate__.remote() for w in self.workers]
|
||||
# Wait for actors to die gracefully.
|
||||
done, not_done = ray.wait(done_refs, timeout=patience_s)
|
||||
if not_done:
|
||||
logger.debug("Graceful termination failed. Falling back to force kill.")
|
||||
# If all actors are not able to die gracefully, then kill them.
|
||||
for worker in self.workers:
|
||||
ray.kill(worker.actor)
|
||||
|
||||
logger.debug("Shutdown successful.")
|
||||
self.workers = []
|
||||
|
||||
def execute_async(self, func: Callable[..., T], *args, **kwargs) -> List[ObjectRef]:
|
||||
"""Execute ``func`` on each worker and return the futures.
|
||||
|
||||
Args:
|
||||
func: A function to call on each worker.
|
||||
*args: Positional arguments passed directly into ``func``.
|
||||
**kwargs: Keyword arguments passed directly into ``func``.
|
||||
|
||||
Returns:
|
||||
(List[ObjectRef]) A list of ``ObjectRef`` representing the
|
||||
output of ``func`` from each worker. The order is the same
|
||||
as ``self.workers``.
|
||||
|
||||
"""
|
||||
if len(self.workers) <= 0:
|
||||
raise RuntimeError(
|
||||
"There are no active workers. This worker "
|
||||
"group has most likely been shut down. Please"
|
||||
"create a new WorkerGroup or restart this one."
|
||||
)
|
||||
|
||||
return [
|
||||
w.actor._RayTrainWorker__execute.options(
|
||||
name=f"_RayTrainWorker__execute.{func.__name__}"
|
||||
).remote(func, *args, **kwargs)
|
||||
for w in self.workers
|
||||
]
|
||||
|
||||
def execute(self, func: Callable[..., T], *args, **kwargs) -> List[T]:
|
||||
"""Execute ``func`` on each worker and return the outputs of ``func``.
|
||||
|
||||
Args:
|
||||
func: A function to call on each worker.
|
||||
*args: Positional arguments passed directly into ``func``.
|
||||
**kwargs: Keyword arguments passed directly into ``func``.
|
||||
|
||||
Returns:
|
||||
(List[T]) A list containing the output of ``func`` from each
|
||||
worker. The order is the same as ``self.workers``.
|
||||
|
||||
"""
|
||||
# TODO: Add a timeout in the case of a hang, particularly
|
||||
# relevant when func is TorchConfig.on_shutdown
|
||||
return ray.get(self.execute_async(func, *args, **kwargs))
|
||||
|
||||
def execute_single_async(
|
||||
self, worker_index: int, func: Callable[..., T], *args, **kwargs
|
||||
) -> ObjectRef:
|
||||
"""Execute ``func`` on worker ``worker_index`` and return futures.
|
||||
|
||||
Args:
|
||||
worker_index: The index to execute func on.
|
||||
func: A function to call on the first worker.
|
||||
*args: Positional arguments passed directly into ``func``.
|
||||
**kwargs: Keyword arguments passed directly into ``func``.
|
||||
|
||||
Returns:
|
||||
(ObjectRef) An ObjectRef representing the output of func.
|
||||
|
||||
"""
|
||||
if worker_index >= len(self.workers):
|
||||
raise ValueError(
|
||||
f"The provided worker_index {worker_index} is "
|
||||
f"not valid for {self.num_workers} workers."
|
||||
)
|
||||
return (
|
||||
self.workers[worker_index]
|
||||
.actor._RayTrainWorker__execute.options(
|
||||
name=f"_RayTrainWorker__execute.{func.__name__}"
|
||||
)
|
||||
.remote(func, *args, **kwargs)
|
||||
)
|
||||
|
||||
def execute_single(
|
||||
self, worker_index: int, func: Callable[..., T], *args, **kwargs
|
||||
) -> T:
|
||||
"""Execute ``func`` on worker with index ``worker_index``.
|
||||
|
||||
Args:
|
||||
worker_index: The index to execute func on.
|
||||
func: A function to call on the first worker.
|
||||
*args: Positional arguments passed directly into ``func``.
|
||||
**kwargs: Keyword arguments passed directly into ``func``.
|
||||
|
||||
Returns:
|
||||
(T) The output of func.
|
||||
|
||||
"""
|
||||
|
||||
return ray.get(self.execute_single_async(worker_index, func, *args, **kwargs))
|
||||
|
||||
def remove_workers(self, worker_indexes: List[int]):
|
||||
"""Removes the workers with the specified indexes.
|
||||
|
||||
The removed workers will go out of scope and their actor processes
|
||||
will be terminated.
|
||||
|
||||
Args:
|
||||
worker_indexes: The indexes of the workers to remove.
|
||||
"""
|
||||
new_workers = []
|
||||
for i in range(len(self.workers)):
|
||||
if i not in worker_indexes:
|
||||
new_workers.append(self.workers[i])
|
||||
self.workers = new_workers
|
||||
|
||||
def add_workers(self, num_workers: int):
|
||||
"""Adds ``num_workers`` to this WorkerGroup.
|
||||
|
||||
Note: Adding workers when the cluster/placement group is at capacity
|
||||
may lead to undefined hanging behavior. If you are attempting to
|
||||
replace existing workers in the WorkerGroup, remove_workers() should
|
||||
be called first.
|
||||
|
||||
Args:
|
||||
num_workers: The number of workers to add.
|
||||
"""
|
||||
new_actors = []
|
||||
new_actor_metadata = []
|
||||
for _ in range(num_workers):
|
||||
actor = self._remote_cls.options(
|
||||
placement_group=self._placement_group
|
||||
).remote(*self._actor_cls_args, **self._actor_cls_kwargs)
|
||||
new_actors.append(actor)
|
||||
new_actor_metadata.append(
|
||||
actor._RayTrainWorker__execute.options(
|
||||
name="_RayTrainWorker__execute.construct_metadata"
|
||||
).remote(construct_metadata)
|
||||
)
|
||||
|
||||
# Get metadata from all actors.
|
||||
metadata = ray.get(new_actor_metadata)
|
||||
|
||||
for i in range(len(new_actors)):
|
||||
self.workers.append(Worker(actor=new_actors[i], metadata=metadata[i]))
|
||||
|
||||
def sort_workers_by_node_id_and_gpu_id(self, _first_node_id: Optional[str] = None):
|
||||
"""Reorder the workers by their node id and the lowest GPU id.
|
||||
|
||||
This is useful for collocating workers on the same node.
|
||||
|
||||
Example:
|
||||
Given workers with the following attributes:
|
||||
worker_0: node_id=1, gpu_ids=[1]
|
||||
worker_1: node_id=0, gpu_ids=[0]
|
||||
worker_2: node_id=1, gpu_ids=[0]
|
||||
worker_3: node_id=0, gpu_ids=[1]
|
||||
|
||||
The function will perform the following steps:
|
||||
1. Group by node ID:
|
||||
node_id=0: worker_1, worker_3
|
||||
node_id=1: worker_0, worker_2
|
||||
|
||||
2. Sort each group by GPU ID:
|
||||
node_id=0: worker_1 (gpu_id=0), worker_3 (gpu_id=1)
|
||||
node_id=1: worker_2 (gpu_id=0), worker_0 (gpu_id=1)
|
||||
|
||||
Resulting in the order: [worker_1, worker_3, worker_2, worker_0]
|
||||
|
||||
Args:
|
||||
_first_node_id: The first ID to group by.
|
||||
Set this to the node ID of the trainer coordinator to ensure that the
|
||||
rank 0 worker is on the same node, allowing additional resources to
|
||||
be specified for rank 0 workers via
|
||||
`ScalingConfig(trainer_resources=)`.
|
||||
"""
|
||||
node_id_to_workers = defaultdict(list)
|
||||
|
||||
if _first_node_id is not None:
|
||||
node_id_to_workers[_first_node_id] = []
|
||||
|
||||
for worker in self.workers:
|
||||
node_id_to_workers[worker.metadata.node_id].append(worker)
|
||||
|
||||
# Sort workers on the same node by the lowest GPU id
|
||||
# More details: https://github.com/ray-project/ray/issues/40803
|
||||
def get_lowest_gpu_id(worker) -> int:
|
||||
gpu_ids = worker.metadata.resource_ids.get("GPU", [])
|
||||
# If there are no GPU IDs, return 0 as a default
|
||||
if not gpu_ids:
|
||||
return 0
|
||||
|
||||
# Attempt to convert GPU IDs to integers and find the minimum ID.
|
||||
# Fallback to return the minimum string-based ID
|
||||
try:
|
||||
return min(int(gpu_id) for gpu_id in gpu_ids)
|
||||
except ValueError:
|
||||
return min(gpu_ids)
|
||||
|
||||
for node_id in node_id_to_workers:
|
||||
node_id_to_workers[node_id].sort(key=get_lowest_gpu_id)
|
||||
|
||||
sorted_workers = []
|
||||
for workers in node_id_to_workers.values():
|
||||
sorted_workers.extend(workers)
|
||||
|
||||
self.workers = sorted_workers
|
||||
|
||||
def __len__(self):
|
||||
return len(self.workers)
|
||||
|
||||
def get_resources_per_worker(self) -> dict:
|
||||
"""Get the resources allocated per worker."""
|
||||
return copy.deepcopy(self.resources_per_worker)
|
||||
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, Dict, TypeVar
|
||||
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train._internal.utils import Singleton
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
from ray.widgets import make_table_html_repr
|
||||
|
||||
EncodedData = TypeVar("EncodedData")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BackendConfig:
|
||||
"""Parent class for configurations of training backend."""
|
||||
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return Backend
|
||||
|
||||
@property
|
||||
def train_func_context(self):
|
||||
return nullcontext
|
||||
|
||||
@property
|
||||
def framework(self):
|
||||
return None
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return make_table_html_repr(obj=self, title=type(self).__name__)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns serializable dictionary representation of the backend config.
|
||||
Subclasses can override this to expose framework-specific configuration.
|
||||
|
||||
The fields here are used for state export of the backend config.
|
||||
If a field is not serializable, it should be excluded.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class Backend(metaclass=Singleton):
|
||||
"""Singleton for distributed communication backend.
|
||||
|
||||
Attributes:
|
||||
share_cuda_visible_devices: If True, each worker
|
||||
process will have CUDA_VISIBLE_DEVICES set as the visible device
|
||||
IDs of all workers on the same node for this training instance.
|
||||
If False, each worker will have CUDA_VISIBLE_DEVICES set to the
|
||||
device IDs allocated by Ray for that worker.
|
||||
"""
|
||||
|
||||
share_cuda_visible_devices: bool = False
|
||||
has_replica_groups: bool = False
|
||||
|
||||
def on_start(self, worker_group: BaseWorkerGroup, backend_config: BackendConfig):
|
||||
"""Logic for starting this backend."""
|
||||
pass
|
||||
|
||||
def on_shutdown(self, worker_group: BaseWorkerGroup, backend_config: BackendConfig):
|
||||
"""Logic for shutting down the backend."""
|
||||
pass
|
||||
|
||||
def on_training_start(
|
||||
self, worker_group: BaseWorkerGroup, backend_config: BackendConfig
|
||||
):
|
||||
"""Logic ran right before training is started.
|
||||
|
||||
Session API is available at this point."""
|
||||
pass
|
||||
@@ -0,0 +1,954 @@
|
||||
import abc
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import pyarrow.fs
|
||||
|
||||
import ray
|
||||
import ray.cloudpickle as pickle
|
||||
from ray._common.usage import usage_lib
|
||||
from ray._private.dict import deep_update
|
||||
from ray.air._internal import usage as air_usage
|
||||
from ray.air._internal.config import ensure_only_allowed_dataclass_keys_updated
|
||||
from ray.air._internal.usage import AirEntrypoint
|
||||
from ray.air.config import RunConfig, ScalingConfig
|
||||
from ray.air.result import Result
|
||||
from ray.train import Checkpoint
|
||||
from ray.train._internal.session import get_session
|
||||
from ray.train._internal.storage import (
|
||||
StorageContext,
|
||||
_exists_at_fs_path,
|
||||
get_fs_and_path,
|
||||
)
|
||||
from ray.train.constants import (
|
||||
V2_MIGRATION_GUIDE_MESSAGE,
|
||||
_v2_migration_warnings_enabled,
|
||||
)
|
||||
from ray.train.context import _GET_METADATA_DEPRECATION_MESSAGE
|
||||
from ray.train.utils import _log_deprecation_warning
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data import Dataset
|
||||
from ray.tune import Trainable
|
||||
|
||||
_TRAINER_PKL = "trainer.pkl"
|
||||
|
||||
# A type representing either a ray.data.Dataset or a function that returns a
|
||||
# ray.data.Dataset and accepts no arguments.
|
||||
GenDataset = Union["Dataset", Callable[[], "Dataset"]]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREPROCESSOR_DEPRECATION_MESSAGE = (
|
||||
"The `preprocessor` argument to Trainers is deprecated as of Ray 2.7. "
|
||||
"Instead, use the Preprocessor `fit` and `transform` APIs directly on the Ray "
|
||||
"Dataset. For any state that needs to be saved to the trained checkpoint, pass it "
|
||||
"in using the `metadata` argument of the `Trainer`. "
|
||||
"For a full example, see "
|
||||
"https://docs.ray.io/en/master/train/user-guides/data-loading-preprocessing.html#preprocessing-structured-data " # noqa:E501
|
||||
)
|
||||
|
||||
_TRAINER_RESTORE_DEPRECATION_WARNING = (
|
||||
"The `restore` and `can_restore` APIs are deprecated and "
|
||||
f"will be removed in a future release. {V2_MIGRATION_GUIDE_MESSAGE}"
|
||||
)
|
||||
|
||||
_RESUME_FROM_CHECKPOINT_DEPRECATION_WARNING = (
|
||||
"`resume_from_checkpoint` is deprecated and will be removed in an upcoming "
|
||||
f"release. {V2_MIGRATION_GUIDE_MESSAGE}"
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class TrainingFailedError(RuntimeError):
|
||||
"""An error indicating that training has failed."""
|
||||
|
||||
_RESTORE_MSG = (
|
||||
"The Ray Train run failed. Please inspect the previous error messages for a "
|
||||
"cause. After fixing the issue (assuming that the error is not caused by "
|
||||
"your own application logic, but rather an error such as OOM), you can restart "
|
||||
"the run from scratch or continue this run.\n"
|
||||
"To continue this run, you can use: "
|
||||
'`trainer = {trainer_cls_name}.restore("{path}")`.'
|
||||
)
|
||||
|
||||
_FAILURE_CONFIG_MSG = (
|
||||
"To start a new run that will retry on training failures, set "
|
||||
"`train.RunConfig(failure_config=train.FailureConfig(max_failures))` "
|
||||
"in the Trainer's `run_config` with `max_failures > 0`, or `max_failures = -1` "
|
||||
"for unlimited retries."
|
||||
)
|
||||
|
||||
|
||||
def _train_coordinator_fn(
|
||||
config: dict, trainer_cls: Type["BaseTrainer"], metadata: dict
|
||||
):
|
||||
"""This is the function that defines the logic of the Ray Train coordinator.
|
||||
This is responsible for setting up a remote instance of the `trainer_cls`
|
||||
(a different instance than the one calling `trainer.fit` on the driver!)
|
||||
and running the training loop.
|
||||
"""
|
||||
assert metadata is not None, metadata
|
||||
# Propagate user metadata from the Trainer constructor.
|
||||
get_session().metadata = metadata
|
||||
|
||||
# config already contains merged values.
|
||||
# Instantiate new Trainer in Trainable.
|
||||
trainer = trainer_cls(**config)
|
||||
|
||||
# Get the checkpoint from Tune and pass it to workers later on.
|
||||
checkpoint = ray.tune.get_checkpoint()
|
||||
if checkpoint:
|
||||
# Set `starting_checkpoint` for auto-recovery fault-tolerance
|
||||
# as well as manual restoration.
|
||||
trainer.starting_checkpoint = checkpoint
|
||||
# else: Train will restore from the user-provided
|
||||
# `resume_from_checkpoint` == `starting_checkpoint`.
|
||||
|
||||
# Evaluate datasets if they are wrapped in a factory.
|
||||
trainer.datasets = {
|
||||
k: d() if callable(d) else d for k, d in trainer.datasets.items()
|
||||
}
|
||||
|
||||
trainer.setup()
|
||||
trainer.training_loop()
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BaseTrainer(abc.ABC):
|
||||
"""Defines interface for distributed training on Ray.
|
||||
|
||||
Note: The base ``BaseTrainer`` class cannot be instantiated directly. Only
|
||||
one of its subclasses can be used.
|
||||
|
||||
Note to developers: If a new trainer is added, please update
|
||||
`air/_internal/usage.py`.
|
||||
|
||||
**How does a trainer work?**
|
||||
|
||||
- First, initialize the Trainer. The initialization runs locally,
|
||||
so heavyweight setup should not be done in ``__init__``.
|
||||
- Then, when you call ``trainer.fit()``, the Trainer is serialized
|
||||
and copied to a remote Ray actor. The following methods are then
|
||||
called in sequence on the remote actor.
|
||||
- ``trainer.setup()``: Any heavyweight Trainer setup should be
|
||||
specified here.
|
||||
- ``trainer.training_loop()``: Executes the main training logic.
|
||||
- Calling ``trainer.fit()`` will return a ``ray.result.Result``
|
||||
object where you can access metrics from your training run, as well
|
||||
as any checkpoints that may have been saved.
|
||||
|
||||
**How do I create a new Trainer?**
|
||||
|
||||
Subclass ``ray.train.trainer.BaseTrainer``, and override the ``training_loop``
|
||||
method, and optionally ``setup``.
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import torch
|
||||
|
||||
from ray.train.trainer import BaseTrainer
|
||||
from ray import train, tune
|
||||
|
||||
|
||||
class MyPytorchTrainer(BaseTrainer):
|
||||
def setup(self):
|
||||
self.model = torch.nn.Linear(1, 1)
|
||||
self.optimizer = torch.optim.SGD(
|
||||
self.model.parameters(), lr=0.1)
|
||||
|
||||
def training_loop(self):
|
||||
# You can access any Trainer attributes directly in this method.
|
||||
# self.datasets["train"] has already been
|
||||
dataset = self.datasets["train"]
|
||||
|
||||
torch_ds = dataset.iter_torch_batches(dtypes=torch.float)
|
||||
loss_fn = torch.nn.MSELoss()
|
||||
|
||||
for epoch_idx in range(10):
|
||||
loss = 0
|
||||
num_batches = 0
|
||||
torch_ds = dataset.iter_torch_batches(
|
||||
dtypes=torch.float, batch_size=2
|
||||
)
|
||||
for batch in torch_ds:
|
||||
X = torch.unsqueeze(batch["x"], 1)
|
||||
y = torch.unsqueeze(batch["y"], 1)
|
||||
# Compute prediction error
|
||||
pred = self.model(X)
|
||||
batch_loss = loss_fn(pred, y)
|
||||
|
||||
# Backpropagation
|
||||
self.optimizer.zero_grad()
|
||||
batch_loss.backward()
|
||||
self.optimizer.step()
|
||||
|
||||
loss += batch_loss.item()
|
||||
num_batches += 1
|
||||
loss /= num_batches
|
||||
|
||||
# Use Tune functions to report intermediate
|
||||
# results.
|
||||
train.report({"loss": loss, "epoch": epoch_idx})
|
||||
|
||||
|
||||
# Initialize the Trainer, and call Trainer.fit()
|
||||
import ray
|
||||
train_dataset = ray.data.from_items(
|
||||
[{"x": i, "y": i} for i in range(10)])
|
||||
my_trainer = MyPytorchTrainer(datasets={"train": train_dataset})
|
||||
result = my_trainer.fit()
|
||||
|
||||
Args:
|
||||
scaling_config: Configuration for how to scale training.
|
||||
run_config: Configuration for the execution of the training run.
|
||||
datasets: Any Datasets to use for training. Use the key "train"
|
||||
to denote which dataset is the training dataset.
|
||||
metadata: Dict that should be made available via
|
||||
`train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
"""
|
||||
|
||||
_scaling_config_allowed_keys: List[str] = [
|
||||
"trainer_resources",
|
||||
]
|
||||
_handles_checkpoint_freq: bool = False
|
||||
_handles_checkpoint_at_end: bool = False
|
||||
|
||||
# fields to propagate to Tuner param_space.
|
||||
# See `BaseTrainer._extract_fields_for_tuner_param_space` for more details.
|
||||
_fields_for_tuner_param_space = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
run_config: Optional[RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
self.scaling_config = (
|
||||
scaling_config if scaling_config is not None else ScalingConfig()
|
||||
)
|
||||
self.run_config = (
|
||||
copy.copy(run_config) if run_config is not None else RunConfig()
|
||||
)
|
||||
self.metadata = metadata
|
||||
self.datasets = datasets if datasets is not None else {}
|
||||
self.starting_checkpoint = resume_from_checkpoint
|
||||
|
||||
if _v2_migration_warnings_enabled():
|
||||
if metadata is not None:
|
||||
_log_deprecation_warning(_GET_METADATA_DEPRECATION_MESSAGE)
|
||||
if resume_from_checkpoint is not None:
|
||||
_log_deprecation_warning(_RESUME_FROM_CHECKPOINT_DEPRECATION_WARNING)
|
||||
|
||||
# These attributes should only be set through `BaseTrainer.restore`
|
||||
self._restore_path = None
|
||||
self._restore_storage_filesystem = None
|
||||
|
||||
self._validate_attributes()
|
||||
|
||||
usage_lib.record_library_usage("train")
|
||||
air_usage.tag_air_trainer(self)
|
||||
|
||||
@classmethod
|
||||
@Deprecated(message=_TRAINER_RESTORE_DEPRECATION_WARNING)
|
||||
def restore(
|
||||
cls: Type["BaseTrainer"],
|
||||
path: Union[str, os.PathLike],
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
**kwargs,
|
||||
) -> "BaseTrainer":
|
||||
"""Restores a Train experiment from a previously interrupted/failed run.
|
||||
|
||||
Restore should be used for experiment-level fault tolerance in the event
|
||||
that the head node crashes (e.g., OOM or some other runtime error) or the
|
||||
entire cluster goes down (e.g., network error affecting all nodes).
|
||||
|
||||
A run that has already completed successfully will not be resumed from this API.
|
||||
To continue training from a successful run, launch a new run with the
|
||||
``<Framework>Trainer(resume_from_checkpoint)`` API instead, passing in a
|
||||
checkpoint from the previous run to start with.
|
||||
|
||||
.. warning::
|
||||
|
||||
The ``path`` must point to a **trusted** experiment directory.
|
||||
Restoring from an untrusted path executes arbitrary Python code
|
||||
(the experiment state uses pickle serialization). Never restore
|
||||
from a path that other parties can write to.
|
||||
|
||||
.. note::
|
||||
|
||||
Restoring an experiment from a path that's pointing to a *different*
|
||||
location than the original experiment path is supported. However, Ray Train
|
||||
assumes that the full experiment directory is available
|
||||
(including checkpoints) so that it's possible to resume trials from their
|
||||
latest state.
|
||||
|
||||
For example, if the original experiment path was run locally, then the
|
||||
results are uploaded to cloud storage, Ray Train expects the full contents
|
||||
to be available in cloud storage if attempting to resume
|
||||
via ``<Framework>Trainer.restore("s3://...")``. The restored run will
|
||||
continue writing results to the same cloud storage location.
|
||||
|
||||
The following example can be paired with implementing job retry using
|
||||
:ref:`Ray Jobs <jobs-overview>` to produce a Train experiment that will
|
||||
attempt to resume on both experiment-level and trial-level failures:
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import os
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train.trainer import BaseTrainer
|
||||
|
||||
experiment_name = "unique_experiment_name"
|
||||
storage_path = os.path.expanduser("~/ray_results")
|
||||
experiment_dir = os.path.join(storage_path, experiment_name)
|
||||
|
||||
# Define some dummy inputs for demonstration purposes
|
||||
datasets = {"train": ray.data.from_items([{"a": i} for i in range(10)])}
|
||||
|
||||
class CustomTrainer(BaseTrainer):
|
||||
def training_loop(self):
|
||||
pass
|
||||
|
||||
if CustomTrainer.can_restore(experiment_dir):
|
||||
trainer = CustomTrainer.restore(
|
||||
experiment_dir, datasets=datasets
|
||||
)
|
||||
else:
|
||||
trainer = CustomTrainer(
|
||||
datasets=datasets,
|
||||
run_config=train.RunConfig(
|
||||
name=experiment_name,
|
||||
storage_path=storage_path,
|
||||
# Tip: You can also enable retries on failure for
|
||||
# worker-level fault tolerance
|
||||
failure_config=train.FailureConfig(max_failures=3),
|
||||
),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
Args:
|
||||
path: The path to the experiment directory of the training run to restore.
|
||||
This can be a local path or a remote URI if the experiment was
|
||||
uploaded to the cloud.
|
||||
storage_filesystem: Custom ``pyarrow.fs.FileSystem``
|
||||
corresponding to the ``path``. This may be necessary if the original
|
||||
experiment passed in a custom filesystem.
|
||||
datasets: Re-specified datasets used in the original training run.
|
||||
This must include all the datasets that were passed in the
|
||||
original trainer constructor.
|
||||
scaling_config: Optionally re-specified scaling config. This can be
|
||||
modified to be different from the original spec.
|
||||
**kwargs: Other optionally re-specified arguments, passed in by subclasses.
|
||||
|
||||
Raises:
|
||||
ValueError: If all datasets were not re-supplied on restore.
|
||||
|
||||
Returns:
|
||||
BaseTrainer: A restored instance of the class that is calling this method.
|
||||
"""
|
||||
if _v2_migration_warnings_enabled():
|
||||
_log_deprecation_warning(_TRAINER_RESTORE_DEPRECATION_WARNING)
|
||||
|
||||
if not cls.can_restore(path, storage_filesystem):
|
||||
raise ValueError(
|
||||
f"Invalid restore path: {path}. Make sure that this path exists and "
|
||||
"is the experiment directory that results from a call to "
|
||||
"`trainer.fit()`."
|
||||
)
|
||||
fs, fs_path = get_fs_and_path(path, storage_filesystem)
|
||||
trainer_pkl_path = Path(fs_path, _TRAINER_PKL).as_posix()
|
||||
with fs.open_input_file(trainer_pkl_path) as f:
|
||||
trainer_cls, param_dict = pickle.loads(f.readall())
|
||||
|
||||
if trainer_cls is not cls:
|
||||
warnings.warn(
|
||||
f"Invalid trainer type. You are attempting to restore a trainer of type"
|
||||
f" {trainer_cls} with `{cls.__name__}.restore`, "
|
||||
"which will most likely fail. "
|
||||
f"Use `{trainer_cls.__name__}.restore` instead."
|
||||
)
|
||||
|
||||
original_datasets = param_dict.pop("datasets", {})
|
||||
if original_datasets and not datasets:
|
||||
raise ValueError(
|
||||
"The following datasets need to be provided again on restore: "
|
||||
f"{list(original_datasets.keys())}\n"
|
||||
f"Use {cls.__name__}.restore(..., datasets=datasets) "
|
||||
"with the datasets that were provided to the original trainer."
|
||||
)
|
||||
datasets = datasets or {}
|
||||
if set(original_datasets) != set(datasets):
|
||||
raise ValueError(
|
||||
"The provided datasets don't match the original dataset keys.\n"
|
||||
f" Expected datasets for the keys: {list(original_datasets.keys())}\n"
|
||||
f" Actual datasets provided: {list(datasets.keys())}"
|
||||
)
|
||||
param_dict["datasets"] = datasets
|
||||
|
||||
if scaling_config:
|
||||
param_dict["scaling_config"] = scaling_config
|
||||
|
||||
for param_name, val in kwargs.items():
|
||||
# Overwrite the old value if something is passed into restore
|
||||
if val is not None:
|
||||
param_dict[param_name] = val
|
||||
|
||||
try:
|
||||
trainer = cls(**param_dict)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
"Trainer restoration failed (see above for the stack trace). "
|
||||
"Make sure that you use the right trainer class to restore: "
|
||||
f"`{cls.__name__}.restore`\n"
|
||||
) from e
|
||||
trainer._restore_path = path
|
||||
trainer._restore_storage_filesystem = storage_filesystem
|
||||
return trainer
|
||||
|
||||
@classmethod
|
||||
@Deprecated(
|
||||
message=_TRAINER_RESTORE_DEPRECATION_WARNING,
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
def can_restore(
|
||||
cls: Type["BaseTrainer"],
|
||||
path: Union[str, os.PathLike],
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
) -> bool:
|
||||
"""Checks whether a given directory contains a restorable Train experiment.
|
||||
|
||||
Args:
|
||||
path: The path to the experiment directory of the Train experiment.
|
||||
This can be either a local directory (e.g., ~/ray_results/exp_name)
|
||||
or a remote URI (e.g., s3://bucket/exp_name).
|
||||
storage_filesystem: Custom ``pyarrow.fs.FileSystem`` to use. If not
|
||||
provided, the filesystem is auto-resolved from ``path``.
|
||||
|
||||
Returns:
|
||||
bool: Whether this path exists and contains the trainer state to resume from
|
||||
"""
|
||||
if _v2_migration_warnings_enabled():
|
||||
_log_deprecation_warning(_TRAINER_RESTORE_DEPRECATION_WARNING)
|
||||
|
||||
fs, fs_path = get_fs_and_path(path, storage_filesystem)
|
||||
trainer_pkl_path = Path(fs_path, _TRAINER_PKL).as_posix()
|
||||
return _exists_at_fs_path(fs, trainer_pkl_path)
|
||||
|
||||
def __repr__(self):
|
||||
# A dictionary that maps parameters to their default values.
|
||||
default_values: Dict[str, Any] = {
|
||||
"scaling_config": ScalingConfig(),
|
||||
"run_config": RunConfig(),
|
||||
"datasets": {},
|
||||
"starting_checkpoint": None,
|
||||
}
|
||||
|
||||
non_default_arguments = []
|
||||
for parameter, default_value in default_values.items():
|
||||
value = getattr(self, parameter)
|
||||
if value != default_value:
|
||||
# 'Dataset.__repr__' returns a table rather than a regular Python object
|
||||
# representation. So, we need to special case the 'datasets' parameter.
|
||||
if parameter == "datasets":
|
||||
value_repr = format_datasets_for_repr(value)
|
||||
else:
|
||||
value_repr = repr(value)
|
||||
|
||||
non_default_arguments.append(f"{parameter}={value_repr}")
|
||||
|
||||
if non_default_arguments:
|
||||
return f"<{self.__class__.__name__} {' '.join(non_default_arguments)}>"
|
||||
|
||||
return f"<{self.__class__.__name__}>"
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
# Store the init args as attributes so this can be merged with Tune hparams.
|
||||
trainer = super(BaseTrainer, cls).__new__(cls)
|
||||
parameters = inspect.signature(cls.__init__).parameters
|
||||
parameters = list(parameters.keys())
|
||||
# Remove self.
|
||||
parameters = parameters[1:]
|
||||
arg_dict = dict(zip(parameters, args))
|
||||
trainer._param_dict = {**arg_dict, **kwargs}
|
||||
return trainer
|
||||
|
||||
def _validate_attributes(self):
|
||||
"""Called on __init()__ to validate trainer attributes."""
|
||||
# Run config
|
||||
if not isinstance(self.run_config, RunConfig):
|
||||
raise ValueError(
|
||||
f"`run_config` should be an instance of `ray.train.RunConfig`, "
|
||||
f"found {type(self.run_config)} with value `{self.run_config}`."
|
||||
)
|
||||
# Scaling config
|
||||
if not isinstance(self.scaling_config, ScalingConfig):
|
||||
raise ValueError(
|
||||
"`scaling_config` should be an instance of `ScalingConfig`, "
|
||||
f"found {type(self.scaling_config)} with value `{self.scaling_config}`."
|
||||
)
|
||||
# Datasets
|
||||
if not isinstance(self.datasets, dict):
|
||||
raise ValueError(
|
||||
f"`datasets` should be a dict mapping from a string to "
|
||||
f"`ray.data.Dataset` objects, "
|
||||
f"found {type(self.datasets)} with value `{self.datasets}`."
|
||||
)
|
||||
else:
|
||||
for key, dataset in self.datasets.items():
|
||||
if not isinstance(dataset, ray.data.Dataset) and not callable(dataset):
|
||||
raise ValueError(
|
||||
f"The Dataset under '{key}' key is not a "
|
||||
"`ray.data.Dataset`. "
|
||||
f"Received {dataset} instead."
|
||||
)
|
||||
# Metadata.
|
||||
self.metadata = self.metadata or {}
|
||||
if not isinstance(self.metadata, dict):
|
||||
raise TypeError(
|
||||
f"The provided metadata must be a dict, was {type(self.metadata)}."
|
||||
)
|
||||
try:
|
||||
self.metadata = json.loads(json.dumps(self.metadata))
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
"The provided metadata must be JSON-serializable: "
|
||||
f"{self.metadata}: {e}"
|
||||
)
|
||||
|
||||
if self.starting_checkpoint is not None and not isinstance(
|
||||
self.starting_checkpoint, Checkpoint
|
||||
):
|
||||
raise ValueError(
|
||||
f"`resume_from_checkpoint` should be an instance of "
|
||||
f"`ray.train.Checkpoint`, found {type(self.starting_checkpoint)} "
|
||||
f"with value `{self.starting_checkpoint}`."
|
||||
)
|
||||
|
||||
self._log_v2_deprecation_warnings()
|
||||
|
||||
def _log_v2_deprecation_warnings(self):
|
||||
"""Logs deprecation warnings for v2 migration.
|
||||
|
||||
Log them here in the Ray Train case rather than in the configuration
|
||||
constructors to avoid logging incorrect deprecation warnings when
|
||||
`ray.train.RunConfig` is passed to Ray Tune.
|
||||
"""
|
||||
from ray.train.v2._internal.constants import V2_ENABLED_ENV_VAR, is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
raise DeprecationWarning(
|
||||
f"Detected use of a deprecated Trainer import from `{self.__class__.__module__}`. "
|
||||
"This Trainer class is not compatible with Ray Train V2.\n"
|
||||
"To fix this:\n"
|
||||
" - Update to use the new import path. For example, "
|
||||
"`from ray.train.torch.torch_trainer import TorchTrainer` -> "
|
||||
"`from ray.train.torch import TorchTrainer`\n"
|
||||
f" - Or, explicitly disable V2 by setting: {V2_ENABLED_ENV_VAR}=0\n"
|
||||
"See this issue for more context: "
|
||||
"https://github.com/ray-project/ray/issues/49454"
|
||||
)
|
||||
|
||||
if not _v2_migration_warnings_enabled():
|
||||
return
|
||||
|
||||
from ray.train.v2._internal.migration_utils import (
|
||||
CALLBACKS_DEPRECATION_MESSAGE,
|
||||
FAIL_FAST_DEPRECATION_MESSAGE,
|
||||
LOG_TO_FILE_DEPRECATION_MESSAGE,
|
||||
PROGRESS_REPORTER_DEPRECATION_MESSAGE,
|
||||
STOP_DEPRECATION_MESSAGE,
|
||||
SYNC_CONFIG_DEPRECATION_MESSAGE,
|
||||
TRAINER_RESOURCES_DEPRECATION_MESSAGE,
|
||||
VERBOSE_DEPRECATION_MESSAGE,
|
||||
)
|
||||
|
||||
# ScalingConfig deprecations
|
||||
if self.scaling_config.trainer_resources is not None:
|
||||
_log_deprecation_warning(TRAINER_RESOURCES_DEPRECATION_MESSAGE)
|
||||
|
||||
# FailureConfig deprecations
|
||||
if self.run_config.failure_config.fail_fast:
|
||||
_log_deprecation_warning(FAIL_FAST_DEPRECATION_MESSAGE)
|
||||
|
||||
# RunConfig deprecations
|
||||
# NOTE: _verbose is the original verbose value passed by the user
|
||||
if self.run_config._verbose is not None:
|
||||
_log_deprecation_warning(VERBOSE_DEPRECATION_MESSAGE)
|
||||
|
||||
if self.run_config.log_to_file:
|
||||
_log_deprecation_warning(LOG_TO_FILE_DEPRECATION_MESSAGE)
|
||||
|
||||
if self.run_config.stop is not None:
|
||||
_log_deprecation_warning(STOP_DEPRECATION_MESSAGE)
|
||||
|
||||
if self.run_config.callbacks is not None:
|
||||
_log_deprecation_warning(CALLBACKS_DEPRECATION_MESSAGE)
|
||||
|
||||
if self.run_config.progress_reporter is not None:
|
||||
_log_deprecation_warning(PROGRESS_REPORTER_DEPRECATION_MESSAGE)
|
||||
|
||||
if self.run_config.sync_config != ray.train.SyncConfig():
|
||||
_log_deprecation_warning(SYNC_CONFIG_DEPRECATION_MESSAGE)
|
||||
|
||||
@classmethod
|
||||
def _validate_scaling_config(cls, scaling_config: ScalingConfig) -> ScalingConfig:
|
||||
"""Returns scaling config dataclass after validating updated keys."""
|
||||
ensure_only_allowed_dataclass_keys_updated(
|
||||
dataclass=scaling_config,
|
||||
allowed_keys=cls._scaling_config_allowed_keys,
|
||||
)
|
||||
return scaling_config
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Called during fit() to perform initial setup on the Trainer.
|
||||
|
||||
.. note:: This method is run on a remote process.
|
||||
|
||||
This method will not be called on the driver, so any expensive setup
|
||||
operations should be placed here and not in ``__init__``.
|
||||
|
||||
This method is called prior to ``preprocess_datasets`` and
|
||||
``training_loop``.
|
||||
"""
|
||||
pass
|
||||
|
||||
def preprocess_datasets(self) -> None:
|
||||
"""Deprecated."""
|
||||
raise DeprecationWarning(
|
||||
"`preprocess_datasets` is no longer used, since preprocessors "
|
||||
f"are no longer accepted by Trainers.\n{PREPROCESSOR_DEPRECATION_MESSAGE}"
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def training_loop(self) -> None:
|
||||
"""Loop called by fit() to run training and report results to Tune.
|
||||
|
||||
.. note:: This method runs on a remote process.
|
||||
|
||||
``self.datasets`` have already been evaluated if they were wrapped in a factory.
|
||||
|
||||
You can use the :ref:`Ray Train utilities <train-loop-api>`
|
||||
(:func:`train.report() <ray.train.report>` and
|
||||
:func:`train.get_checkpoint() <ray.train.get_checkpoint>`) inside
|
||||
this training loop.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.train.trainer import BaseTrainer
|
||||
from ray import train
|
||||
|
||||
class MyTrainer(BaseTrainer):
|
||||
def training_loop(self):
|
||||
for epoch_idx in range(5):
|
||||
...
|
||||
train.report({"epoch": epoch_idx})
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def fit(self) -> Result:
|
||||
"""Runs training.
|
||||
|
||||
Returns:
|
||||
A Result object containing the training result.
|
||||
|
||||
Raises:
|
||||
ray.train.base_trainer.TrainingFailedError: If any failures during the execution
|
||||
of ``self.as_trainable()``, or during the Tune execution loop.
|
||||
"""
|
||||
from ray.tune import ResumeConfig, TuneError
|
||||
from ray.tune.tuner import Tuner
|
||||
|
||||
trainable = self.as_trainable()
|
||||
param_space = self._extract_fields_for_tuner_param_space()
|
||||
|
||||
self.run_config.name = (
|
||||
self.run_config.name or StorageContext.get_experiment_dir_name(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,
|
||||
)
|
||||
|
||||
if self._restore_path:
|
||||
tuner = Tuner.restore(
|
||||
path=self._restore_path,
|
||||
trainable=trainable,
|
||||
param_space=param_space,
|
||||
_resume_config=ResumeConfig(
|
||||
finished=ResumeConfig.ResumeType.RESUME,
|
||||
unfinished=ResumeConfig.ResumeType.RESUME,
|
||||
errored=ResumeConfig.ResumeType.RESUME,
|
||||
),
|
||||
storage_filesystem=self._restore_storage_filesystem,
|
||||
)
|
||||
else:
|
||||
tuner = Tuner(
|
||||
trainable=trainable,
|
||||
param_space=param_space,
|
||||
run_config=self.run_config,
|
||||
_entrypoint=AirEntrypoint.TRAINER,
|
||||
)
|
||||
|
||||
self._save(storage.storage_filesystem, storage.experiment_fs_path)
|
||||
|
||||
restore_msg = TrainingFailedError._RESTORE_MSG.format(
|
||||
trainer_cls_name=self.__class__.__name__,
|
||||
path=str(storage.experiment_fs_path),
|
||||
)
|
||||
|
||||
try:
|
||||
result_grid = tuner.fit()
|
||||
except TuneError as e:
|
||||
# Catch any `TuneError`s raised by the `Tuner.fit` call.
|
||||
# Unwrap the `TuneError` if needed.
|
||||
parent_error = e.__cause__ or e
|
||||
|
||||
# Raise it to the user as a `TrainingFailedError` with a message to restore.
|
||||
raise TrainingFailedError(restore_msg) from parent_error
|
||||
# Other exceptions get passed through directly (ex: on `fail_fast='raise'`)
|
||||
|
||||
assert len(result_grid) == 1
|
||||
result = result_grid[0]
|
||||
if result.error:
|
||||
# Raise trainable errors to the user with a message to restore
|
||||
# or configure `FailureConfig` in a new run.
|
||||
raise TrainingFailedError(
|
||||
"\n".join([restore_msg, TrainingFailedError._FAILURE_CONFIG_MSG])
|
||||
) from result.error
|
||||
return result
|
||||
|
||||
def _save(self, fs: pyarrow.fs.FileSystem, experiment_path: str):
|
||||
"""Saves the current trainer's class along with the `param_dict` of
|
||||
parameters passed to this trainer's constructor.
|
||||
|
||||
This is used to recreate the trainer on restore.
|
||||
Unless a parameter is re-specified during restoration (only a subset
|
||||
of parameters can be passed in again), that parameter will be loaded
|
||||
from the saved copy.
|
||||
|
||||
Datasets should not be saved as part of the state. Instead, we save the
|
||||
keys and replace the dataset values with dummy functions that will
|
||||
raise an error if invoked. The error only serves as a guardrail for
|
||||
misuse (e.g., manually unpickling and constructing the Trainer again)
|
||||
and is not typically surfaced, since datasets must be re-specified
|
||||
upon restoration.
|
||||
"""
|
||||
param_dict = self._param_dict.copy()
|
||||
datasets = param_dict.pop("datasets", {})
|
||||
|
||||
def raise_fn():
|
||||
raise RuntimeError
|
||||
|
||||
if datasets:
|
||||
param_dict["datasets"] = dict.fromkeys(datasets, raise_fn)
|
||||
|
||||
cls_and_param_dict = (self.__class__, param_dict)
|
||||
|
||||
fs.create_dir(experiment_path)
|
||||
with fs.open_output_stream(Path(experiment_path, _TRAINER_PKL).as_posix()) as f:
|
||||
f.write(pickle.dumps(cls_and_param_dict))
|
||||
|
||||
def _extract_fields_for_tuner_param_space(self) -> Dict:
|
||||
"""Extracts fields to be included in `Tuner.param_space`.
|
||||
|
||||
This is needed to leverage the full logging/integration offerings from Tune.
|
||||
For example, `param_space` is logged automatically to wandb integration.
|
||||
|
||||
Currently only done for `train_loop_config`.
|
||||
|
||||
Returns:
|
||||
A dictionary that should be passed to Tuner.param_space.
|
||||
"""
|
||||
result = {}
|
||||
for key in self._fields_for_tuner_param_space:
|
||||
if key in self._param_dict.keys():
|
||||
result[key] = copy.deepcopy(self._param_dict[key])
|
||||
return result
|
||||
|
||||
def _generate_trainable_cls(self) -> Type["Trainable"]:
|
||||
"""Generates the base Trainable class.
|
||||
|
||||
Returns:
|
||||
A Trainable class to use for training.
|
||||
"""
|
||||
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
from ray.tune.trainable import wrap_function
|
||||
|
||||
trainer_cls = self.__class__
|
||||
scaling_config = self.scaling_config
|
||||
metadata = self.metadata
|
||||
|
||||
train_coordinator_fn = partial(
|
||||
_train_coordinator_fn, trainer_cls=trainer_cls, metadata=metadata
|
||||
)
|
||||
# Change the name of the training function to match the name of the Trainer
|
||||
# class. This will mean the Tune trial name will match the name of Trainer on
|
||||
# stdout messages and the results directory.
|
||||
train_coordinator_fn.__name__ = trainer_cls.__name__
|
||||
|
||||
trainable_cls = wrap_function(train_coordinator_fn)
|
||||
has_base_dataset = bool(self.datasets)
|
||||
if has_base_dataset:
|
||||
from ray.data.context import DataContext
|
||||
|
||||
dataset_context = DataContext.get_current()
|
||||
else:
|
||||
dataset_context = None
|
||||
|
||||
class TrainTrainable(trainable_cls):
|
||||
"""Adds default resources to the Trainable."""
|
||||
|
||||
_handles_checkpoint_freq = trainer_cls._handles_checkpoint_freq
|
||||
_handles_checkpoint_at_end = trainer_cls._handles_checkpoint_at_end
|
||||
|
||||
@classmethod
|
||||
def has_base_dataset(cls) -> bool:
|
||||
"""Whether a dataset is provided through the Trainer."""
|
||||
return has_base_dataset
|
||||
|
||||
@classmethod
|
||||
def base_scaling_config(cls) -> ScalingConfig:
|
||||
"""Returns the unchanged scaling config provided through the Trainer."""
|
||||
return scaling_config
|
||||
|
||||
def setup(self, config, **kwargs):
|
||||
base_config = dict(kwargs)
|
||||
# Merge Tuner param space hyperparameters in `config` into the
|
||||
# base config passed to the Trainer constructor, which is `base_config`.
|
||||
# `base_config` is pulled from the object store from the usage of
|
||||
# tune.with_parameters in `BaseTrainer.as_trainable`.
|
||||
|
||||
# run_config is not a tunable hyperparameter so it does not need to be
|
||||
# merged.
|
||||
run_config = base_config.pop("run_config", None)
|
||||
self._merged_config = deep_update(
|
||||
base_config, self.config, new_keys_allowed=True
|
||||
)
|
||||
self._merged_config["run_config"] = run_config
|
||||
merged_scaling_config = self._merged_config.get(
|
||||
"scaling_config", ScalingConfig()
|
||||
)
|
||||
if isinstance(merged_scaling_config, dict):
|
||||
merged_scaling_config = ScalingConfig(**merged_scaling_config)
|
||||
self._merged_config[
|
||||
"scaling_config"
|
||||
] = self._reconcile_scaling_config_with_trial_resources(
|
||||
merged_scaling_config
|
||||
)
|
||||
if self.has_base_dataset():
|
||||
# Set the DataContext on the Trainer actor to the DataContext
|
||||
# specified on the driver.
|
||||
DataContext._set_current(dataset_context)
|
||||
super(TrainTrainable, self).setup(config)
|
||||
|
||||
def _reconcile_scaling_config_with_trial_resources(
|
||||
self, scaling_config: ScalingConfig
|
||||
) -> ScalingConfig:
|
||||
"""
|
||||
ResourceChangingScheduler workaround.
|
||||
|
||||
Ensures that the scaling config matches trial resources.
|
||||
|
||||
This should be replaced with RCS returning a ScalingConfig
|
||||
in the future.
|
||||
"""
|
||||
|
||||
trial_resources = self.trial_resources
|
||||
# This will be false if the resources are default
|
||||
if not isinstance(trial_resources, PlacementGroupFactory):
|
||||
return scaling_config
|
||||
|
||||
# Ignore ResourceChangingScheduler workaround when resource bundles
|
||||
# are unchanged
|
||||
if self.trial_resources == scaling_config.as_placement_group_factory():
|
||||
return scaling_config
|
||||
|
||||
trainer_cls._validate_scaling_config(scaling_config)
|
||||
|
||||
return ScalingConfig.from_placement_group_factory(trial_resources)
|
||||
|
||||
def _trainable_func(self, config):
|
||||
# We ignore the config passed by Tune and instead use the merged
|
||||
# config which includes the initial Trainer args.
|
||||
super()._trainable_func(self._merged_config)
|
||||
|
||||
@classmethod
|
||||
def default_resource_request(cls, config):
|
||||
# `config["scaling_config"] is a dataclass when passed via the
|
||||
# `scaling_config` argument in `Trainer` and is a dict when passed
|
||||
# via the `scaling_config` key of `param_spec`.
|
||||
|
||||
# Conversion logic must be duplicated in `TrainTrainable.__init__`
|
||||
# because this is a class method.
|
||||
updated_scaling_config = config.get("scaling_config", scaling_config)
|
||||
if isinstance(updated_scaling_config, dict):
|
||||
updated_scaling_config = ScalingConfig(**updated_scaling_config)
|
||||
validated_scaling_config = trainer_cls._validate_scaling_config(
|
||||
updated_scaling_config
|
||||
)
|
||||
return validated_scaling_config.as_placement_group_factory()
|
||||
|
||||
return TrainTrainable
|
||||
|
||||
def as_trainable(self) -> Type["Trainable"]:
|
||||
"""Converts self to a ``tune.Trainable`` class."""
|
||||
from ray import tune
|
||||
|
||||
base_config = self._param_dict
|
||||
trainable_cls = self._generate_trainable_cls()
|
||||
|
||||
# Wrap with `tune.with_parameters` to handle very large values in base_config
|
||||
return tune.with_parameters(trainable_cls, **base_config)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
def format_datasets_for_repr(datasets: Optional[Dict[str, GenDataset]]) -> str:
|
||||
"""Format datasets for BaseTrainer repr using plan strings.
|
||||
|
||||
The Dataset.__repr__ returns a table rather than a conventional Python object
|
||||
reprentation. To ensure the BaseTrainer representation still looks reasonable, we
|
||||
need to special-case datasets.
|
||||
"""
|
||||
from ray.data import Dataset
|
||||
from ray.data._internal.dataset_repr import build_dataset_summary_repr
|
||||
|
||||
assert datasets is not None, "Expected caller to pass in non-None argument"
|
||||
|
||||
formatted = {}
|
||||
for key, dataset in datasets.items():
|
||||
if isinstance(dataset, Dataset):
|
||||
formatted[key] = build_dataset_summary_repr(dataset)
|
||||
else:
|
||||
formatted[key] = dataset
|
||||
|
||||
return "{" + ", ".join(f"'{key}': {formatted[key]}" for key in datasets) + "}"
|
||||
@@ -0,0 +1,19 @@
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
from ray.train.collective.collectives import barrier, broadcast_from_rank_zero
|
||||
|
||||
__all__ = [
|
||||
"broadcast_from_rank_zero",
|
||||
"barrier",
|
||||
]
|
||||
|
||||
broadcast_from_rank_zero.__module__ = "ray.train.collective"
|
||||
barrier.__module__ = "ray.train.collective"
|
||||
else:
|
||||
raise ImportError(
|
||||
"`ray.train.collective` is only available in Ray Train v2. "
|
||||
"To enable it, please set `RAY_TRAIN_V2_ENABLED=1`."
|
||||
)
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from ray.train.v2._internal.execution.train_fn_utils import get_train_fn_utils
|
||||
from ray.train.v2._internal.util import requires_train_worker
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
T = TypeVar("T", bound=Optional[object])
|
||||
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
@requires_train_worker()
|
||||
def broadcast_from_rank_zero(data: T) -> T:
|
||||
"""Broadcast small (<1kb) data from the rank 0 worker to all other workers.
|
||||
|
||||
Serves as a barrier, meaning that all workers must call this method before
|
||||
the training function can continue.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode:
|
||||
|
||||
from ray.train import get_context
|
||||
from ray.train.collective import broadcast_from_rank_zero
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
def train_func():
|
||||
...
|
||||
if get_context().get_world_rank() == 0:
|
||||
data = {"some_key": "some_value"}
|
||||
else:
|
||||
data = None
|
||||
data = broadcast_from_rank_zero(data)
|
||||
...
|
||||
|
||||
trainer = TorchTrainer(train_func)
|
||||
trainer.fit()
|
||||
|
||||
Args:
|
||||
data: The small (1kb) data to broadcast from the rank 0 worker to all
|
||||
other workers.
|
||||
|
||||
Returns:
|
||||
The data broadcasted from the rank 0 worker.
|
||||
|
||||
Raises:
|
||||
ValueError: If the data is too big.
|
||||
pickle.PicklingError: If the data is not pickleable.
|
||||
TypeError: If the data is not pickleable.
|
||||
"""
|
||||
return get_train_fn_utils().broadcast_from_rank_zero(data)
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
@requires_train_worker()
|
||||
def barrier() -> None:
|
||||
"""Create a barrier across all workers.
|
||||
|
||||
All workers must call this method before the training function can continue.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode:
|
||||
|
||||
from ray.train import get_context
|
||||
from ray.train.collective import barrier
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
def train_func():
|
||||
...
|
||||
print(f"Rank {get_context().get_world_rank()} is waiting at the barrier.")
|
||||
barrier()
|
||||
print(f"Rank {get_context().get_world_rank()} has passed the barrier.")
|
||||
...
|
||||
|
||||
trainer = TorchTrainer(train_func)
|
||||
trainer.fit()
|
||||
"""
|
||||
return get_train_fn_utils().barrier()
|
||||
@@ -0,0 +1,158 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import ray
|
||||
from ray._private.ray_constants import env_bool
|
||||
from ray.air.constants import ( # noqa: F401
|
||||
COPY_DIRECTORY_CHECKPOINTS_INSTEAD_OF_MOVING_ENV,
|
||||
EVALUATION_DATASET_KEY,
|
||||
MODEL_KEY,
|
||||
PREPROCESSOR_KEY,
|
||||
TRAIN_DATASET_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _get_ray_train_session_dir() -> str:
|
||||
assert ray.is_initialized(), "Ray must be initialized to get the session dir."
|
||||
return Path(
|
||||
ray._private.worker._global_node.get_session_dir_path(), "artifacts"
|
||||
).as_posix()
|
||||
|
||||
|
||||
DEFAULT_STORAGE_PATH = Path("~/ray_results").expanduser().as_posix()
|
||||
|
||||
# Autofilled ray.train.report() metrics. Keys should be consistent with Tune.
|
||||
CHECKPOINT_DIR_NAME = "checkpoint_dir_name"
|
||||
TIME_TOTAL_S = "_time_total_s"
|
||||
WORKER_HOSTNAME = "_hostname"
|
||||
WORKER_NODE_IP = "_node_ip"
|
||||
WORKER_PID = "_pid"
|
||||
|
||||
# Will not be reported unless ENABLE_DETAILED_AUTOFILLED_METRICS_ENV
|
||||
# env var is not 0
|
||||
DETAILED_AUTOFILLED_KEYS = {WORKER_HOSTNAME, WORKER_NODE_IP, WORKER_PID, TIME_TOTAL_S}
|
||||
|
||||
# Default filename for JSON logger
|
||||
RESULT_FILE_JSON = "results.json"
|
||||
|
||||
# The name of the subdirectory inside the trainer run_dir to store checkpoints.
|
||||
TRAIN_CHECKPOINT_SUBDIR = "checkpoints"
|
||||
|
||||
# The key to use to specify the checkpoint id for Tune.
|
||||
# This needs to be added to the checkpoint dictionary so if the Tune trial
|
||||
# is restarted, the checkpoint_id can continue to increment.
|
||||
TUNE_CHECKPOINT_ID = "_current_checkpoint_id"
|
||||
|
||||
# Deprecated configs can use this value to detect if the user has set it.
|
||||
# This has type Any to allow it to be assigned to any annotated parameter
|
||||
# without causing type errors.
|
||||
_DEPRECATED_VALUE: Any = "DEPRECATED"
|
||||
|
||||
|
||||
# ==================================================
|
||||
# Train V2 constants
|
||||
# ==================================================
|
||||
|
||||
# Set this to 1 to enable deprecation warnings for V2 migration.
|
||||
ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR = "RAY_TRAIN_ENABLE_V2_MIGRATION_WARNINGS"
|
||||
|
||||
|
||||
V2_MIGRATION_GUIDE_MESSAGE = (
|
||||
"See this issue for more context and migration options: "
|
||||
"https://github.com/ray-project/ray/issues/49454. "
|
||||
"Disable these warnings by setting the environment variable: "
|
||||
f"{ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR}=0"
|
||||
)
|
||||
|
||||
|
||||
def _v2_migration_warnings_enabled() -> bool:
|
||||
return env_bool(ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR, True)
|
||||
|
||||
|
||||
# ==================================================
|
||||
# Environment Variables
|
||||
# ==================================================
|
||||
|
||||
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV = (
|
||||
"TRAIN_RESULT_ENABLE_DETAILED_AUTOFILLED_METRICS"
|
||||
)
|
||||
|
||||
# Integer value which if set will override the value of
|
||||
# Backend.share_cuda_visible_devices. 1 for True, 0 for False.
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV = "TRAIN_ENABLE_SHARE_CUDA_VISIBLE_DEVICES"
|
||||
|
||||
# Integer value which if set will not share HIP accelerator visible devices
|
||||
# across workers. 1 for True (default), 0 for False.
|
||||
ENABLE_SHARE_HIP_VISIBLE_DEVICES_ENV = "TRAIN_ENABLE_SHARE_HIP_VISIBLE_DEVICES"
|
||||
|
||||
# Integer value which if set will not share neuron-core accelerator visible cores
|
||||
# across workers. 1 for True (default), 0 for False.
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV = (
|
||||
"TRAIN_ENABLE_SHARE_NEURON_CORES_ACCELERATOR"
|
||||
)
|
||||
|
||||
# Integer value which if set will not share npu visible devices
|
||||
# across workers. 1 for True (default), 0 for False.
|
||||
ENABLE_SHARE_NPU_RT_VISIBLE_DEVICES_ENV = "TRAIN_ENABLE_SHARE_ASCEND_RT_VISIBLE_DEVICES"
|
||||
|
||||
# Integer value which indicates the number of seconds to wait when creating
|
||||
# the worker placement group before timing out.
|
||||
TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV = "TRAIN_PLACEMENT_GROUP_TIMEOUT_S"
|
||||
|
||||
# Integer value which if set will change the placement group strategy from
|
||||
# PACK to SPREAD. 1 for True, 0 for False.
|
||||
TRAIN_ENABLE_WORKER_SPREAD_ENV = "TRAIN_ENABLE_WORKER_SPREAD"
|
||||
|
||||
# Set this to 0 to disable changing the working directory of each Tune Trainable
|
||||
# or Train worker to the trial directory. Defaults to 1.
|
||||
RAY_CHDIR_TO_TRIAL_DIR = "RAY_CHDIR_TO_TRIAL_DIR"
|
||||
|
||||
# Set this to 1 to count preemption errors toward `FailureConfig(max_failures)`.
|
||||
# Defaults to 0, which always retries on node preemption failures.
|
||||
RAY_TRAIN_COUNT_PREEMPTION_AS_FAILURE = "RAY_TRAIN_COUNT_PREEMPTION_AS_FAILURE"
|
||||
|
||||
# Set this to 1 to start a StateActor and collect information Train Runs
|
||||
# Defaults to 0
|
||||
RAY_TRAIN_ENABLE_STATE_TRACKING = "RAY_TRAIN_ENABLE_STATE_TRACKING"
|
||||
|
||||
# Set this to 1 to only store the checkpoint score attribute with the Checkpoint
|
||||
# in the CheckpointManager. The Result will only have the checkpoint score attribute
|
||||
# but files written to disk like result.json will still have all the metrics.
|
||||
# Defaults to 0.
|
||||
# TODO: this is a temporary solution to avoid CheckpointManager OOM.
|
||||
# See https://github.com/ray-project/ray/pull/54642#issue-3234029360 for more details.
|
||||
TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE = (
|
||||
"TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE"
|
||||
)
|
||||
|
||||
# Seconds to wait for torch process group to shut down.
|
||||
# Shutting down a healthy torch process group, which we may want to do for reasons
|
||||
# like restarting a group of workers if an async checkpoint upload fails, can hang.
|
||||
# This is a workaround until we figure out how to avoid this hang.
|
||||
TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S = "TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S"
|
||||
DEFAULT_TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S = 30
|
||||
|
||||
# Seconds to wait for JAX distributed shutdown.
|
||||
JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S = "JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S"
|
||||
DEFAULT_JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S = 30
|
||||
|
||||
# NOTE: When adding a new environment variable, please track it in this list.
|
||||
TRAIN_ENV_VARS = {
|
||||
ENABLE_DETAILED_AUTOFILLED_METRICS_ENV,
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
|
||||
TRAIN_PLACEMENT_GROUP_TIMEOUT_S_ENV,
|
||||
TRAIN_ENABLE_WORKER_SPREAD_ENV,
|
||||
RAY_CHDIR_TO_TRIAL_DIR,
|
||||
RAY_TRAIN_COUNT_PREEMPTION_AS_FAILURE,
|
||||
RAY_TRAIN_ENABLE_STATE_TRACKING,
|
||||
TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE,
|
||||
TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S,
|
||||
JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S,
|
||||
}
|
||||
|
||||
# Key for AIR Checkpoint metadata in TrainingResult metadata
|
||||
CHECKPOINT_METADATA_KEY = "checkpoint_metadata"
|
||||
|
||||
# Key for AIR Checkpoint world rank in TrainingResult metadata
|
||||
CHECKPOINT_RANK_KEY = "checkpoint_rank"
|
||||
@@ -0,0 +1,144 @@
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from ray.train._internal import session
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train.constants import (
|
||||
V2_MIGRATION_GUIDE_MESSAGE,
|
||||
_v2_migration_warnings_enabled,
|
||||
)
|
||||
from ray.train.utils import _copy_doc, _log_deprecation_warning
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
|
||||
|
||||
# The context singleton on this process.
|
||||
_default_context: "Optional[TrainContext]" = None
|
||||
_context_lock = threading.Lock()
|
||||
|
||||
|
||||
_GET_METADATA_DEPRECATION_MESSAGE = (
|
||||
"`get_metadata` was an experimental API that accessed the metadata passed "
|
||||
"to `<Framework>Trainer(metadata=...)`. This API can be replaced by passing "
|
||||
"the metadata directly to the training function (e.g., via `train_loop_config`). "
|
||||
f"{V2_MIGRATION_GUIDE_MESSAGE}"
|
||||
)
|
||||
|
||||
_TUNE_SPECIFIC_CONTEXT_DEPRECATION_MESSAGE = (
|
||||
"`{}` is deprecated because the concept of a `Trial` will "
|
||||
"soon be removed in Ray Train."
|
||||
"Ray Train will no longer assume that it's running within a Ray Tune `Trial` "
|
||||
"in the future. "
|
||||
f"{V2_MIGRATION_GUIDE_MESSAGE}"
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class TrainContext:
|
||||
"""Context containing metadata that can be accessed within Ray Train workers."""
|
||||
|
||||
@_copy_doc(session.get_experiment_name)
|
||||
def get_experiment_name(self) -> str:
|
||||
return session.get_experiment_name()
|
||||
|
||||
@_copy_doc(session.get_world_size)
|
||||
def get_world_size(self) -> int:
|
||||
return session.get_world_size()
|
||||
|
||||
@_copy_doc(session.get_world_rank)
|
||||
def get_world_rank(self) -> int:
|
||||
return session.get_world_rank()
|
||||
|
||||
@_copy_doc(session.get_local_rank)
|
||||
def get_local_rank(self) -> int:
|
||||
return session.get_local_rank()
|
||||
|
||||
@_copy_doc(session.get_local_world_size)
|
||||
def get_local_world_size(self) -> int:
|
||||
return session.get_local_world_size()
|
||||
|
||||
@_copy_doc(session.get_node_rank)
|
||||
def get_node_rank(self) -> int:
|
||||
return session.get_node_rank()
|
||||
|
||||
@DeveloperAPI
|
||||
@_copy_doc(session.get_storage)
|
||||
def get_storage(self) -> StorageContext:
|
||||
return session.get_storage()
|
||||
|
||||
# Deprecated APIs
|
||||
|
||||
@Deprecated(
|
||||
message=_GET_METADATA_DEPRECATION_MESSAGE,
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
@_copy_doc(session.get_metadata)
|
||||
def get_metadata(self) -> Dict[str, Any]:
|
||||
return session.get_metadata()
|
||||
|
||||
@Deprecated(
|
||||
message=_TUNE_SPECIFIC_CONTEXT_DEPRECATION_MESSAGE.format("get_trial_name"),
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
@_copy_doc(session.get_trial_name)
|
||||
def get_trial_name(self) -> str:
|
||||
return session.get_trial_name()
|
||||
|
||||
@Deprecated(
|
||||
message=_TUNE_SPECIFIC_CONTEXT_DEPRECATION_MESSAGE.format("get_trial_id"),
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
@_copy_doc(session.get_trial_id)
|
||||
def get_trial_id(self) -> str:
|
||||
return session.get_trial_id()
|
||||
|
||||
@Deprecated(
|
||||
message=_TUNE_SPECIFIC_CONTEXT_DEPRECATION_MESSAGE.format(
|
||||
"get_trial_resources"
|
||||
),
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
@_copy_doc(session.get_trial_resources)
|
||||
def get_trial_resources(self) -> "PlacementGroupFactory":
|
||||
return session.get_trial_resources()
|
||||
|
||||
@Deprecated(
|
||||
message=_TUNE_SPECIFIC_CONTEXT_DEPRECATION_MESSAGE.format("get_trial_dir"),
|
||||
warning=_v2_migration_warnings_enabled(),
|
||||
)
|
||||
@_copy_doc(session.get_trial_dir)
|
||||
def get_trial_dir(self) -> str:
|
||||
return session.get_trial_dir()
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def get_context() -> TrainContext:
|
||||
"""Get or create a singleton training context.
|
||||
|
||||
The context is only available within a function passed to Ray Train.
|
||||
|
||||
See the :class:`~ray.train.TrainContext` API reference to see available methods.
|
||||
"""
|
||||
from ray.tune.trainable.trainable_fn_utils import _in_tune_session
|
||||
|
||||
# If we are running in a Tune function, switch to Tune context.
|
||||
if _in_tune_session():
|
||||
from ray.tune import get_context as get_tune_context
|
||||
|
||||
if _v2_migration_warnings_enabled():
|
||||
_log_deprecation_warning(
|
||||
"`ray.train.get_context()` should be switched to "
|
||||
"`ray.tune.get_context()` when running in a function "
|
||||
"passed to Ray Tune. This will be an error in the future. "
|
||||
f"{V2_MIGRATION_GUIDE_MESSAGE}"
|
||||
)
|
||||
return get_tune_context()
|
||||
|
||||
global _default_context
|
||||
|
||||
with _context_lock:
|
||||
if _default_context is None:
|
||||
_default_context = TrainContext()
|
||||
return _default_context
|
||||
@@ -0,0 +1,590 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import ray
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray._private.thirdparty.tabulate.tabulate import tabulate
|
||||
from ray.air.config import RunConfig, ScalingConfig
|
||||
from ray.train import BackendConfig, Checkpoint
|
||||
from ray.train._internal import session
|
||||
from ray.train._internal.backend_executor import BackendExecutor, TrialInfo
|
||||
from ray.train._internal.data_config import DataConfig
|
||||
from ray.train._internal.session import _TrainingResult, get_session
|
||||
from ray.train._internal.utils import construct_train_func, count_required_parameters
|
||||
from ray.train.base_trainer import _TRAINER_RESTORE_DEPRECATION_WARNING
|
||||
from ray.train.constants import RAY_TRAIN_ENABLE_STATE_TRACKING
|
||||
from ray.train.trainer import BaseTrainer, GenDataset, TrainingIterator
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI
|
||||
from ray.widgets import Template
|
||||
from ray.widgets.util import repr_with_fallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DataParallelTrainer(BaseTrainer):
|
||||
"""A Trainer for data parallel training.
|
||||
|
||||
You should subclass this Trainer if your Trainer follows SPMD (single program,
|
||||
multiple data) programming paradigm - you want multiple processes to run the same
|
||||
function, but on different data.
|
||||
|
||||
This Trainer runs the function ``train_loop_per_worker`` on multiple Ray
|
||||
Actors.
|
||||
|
||||
The ``train_loop_per_worker`` function is expected to take in either 0 or 1
|
||||
arguments:
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker():
|
||||
...
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker(config: Dict):
|
||||
...
|
||||
|
||||
If ``train_loop_per_worker`` accepts an argument, then
|
||||
``train_loop_config`` will be passed in as the argument. This is useful if you
|
||||
want to tune the values in ``train_loop_config`` as hyperparameters.
|
||||
|
||||
If the ``datasets`` dict contains a training dataset (denoted by
|
||||
the "train" key), then it will be split into multiple dataset
|
||||
shards that can then be accessed by ``train.get_dataset_shard("train")`` inside
|
||||
``train_loop_per_worker``. All the other datasets will not be split and
|
||||
``train.get_dataset_shard(...)`` will return the entire Dataset.
|
||||
|
||||
Inside the ``train_loop_per_worker`` function, you can use any of the
|
||||
:ref:`Ray Train loop methods <train-loop-api>`.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray import train
|
||||
|
||||
def train_loop_per_worker():
|
||||
# Report intermediate results for callbacks or logging and
|
||||
# checkpoint data.
|
||||
train.report(...)
|
||||
|
||||
# Returns dict of last saved checkpoint.
|
||||
train.get_checkpoint()
|
||||
|
||||
# Returns the Dataset shard for the given key.
|
||||
train.get_dataset_shard("my_dataset")
|
||||
|
||||
# Returns the total number of workers executing training.
|
||||
train.get_context().get_world_size()
|
||||
|
||||
# Returns the rank of this worker.
|
||||
train.get_context().get_world_rank()
|
||||
|
||||
# Returns the rank of the worker on the current node.
|
||||
train.get_context().get_local_rank()
|
||||
|
||||
Any returns from the ``train_loop_per_worker`` will be discarded and not
|
||||
used or persisted anywhere.
|
||||
|
||||
**How do I use DataParallelTrainer or any of its subclasses?**
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
def train_loop_for_worker():
|
||||
dataset_shard_for_this_worker = train.get_dataset_shard("train")
|
||||
|
||||
# 3 items for 3 workers, each worker gets 1 item
|
||||
batches = list(dataset_shard_for_this_worker.iter_batches(batch_size=1))
|
||||
assert len(batches) == 1
|
||||
|
||||
train_dataset = ray.data.from_items([1, 2, 3])
|
||||
assert train_dataset.count() == 3
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_for_worker,
|
||||
scaling_config=ScalingConfig(num_workers=3),
|
||||
datasets={"train": train_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
**How do I develop on top of DataParallelTrainer?**
|
||||
|
||||
In many cases, using DataParallelTrainer directly is sufficient to execute
|
||||
functions on multiple actors.
|
||||
|
||||
However, you may want to subclass ``DataParallelTrainer`` and create a custom
|
||||
Trainer for the following 2 use cases:
|
||||
|
||||
- **Use Case 1:** You want to do data parallel training, but want to have
|
||||
a predefined ``training_loop_per_worker``.
|
||||
|
||||
- **Use Case 2:** You want to implement a custom
|
||||
:py:class:`~ray.train.backend.Backend` that automatically handles
|
||||
additional setup or teardown logic on each actor, so that the users of this
|
||||
new trainer do not have to implement this logic. For example, a
|
||||
``TensorflowTrainer`` can be built on top of ``DataParallelTrainer``
|
||||
that automatically handles setting the proper environment variables for
|
||||
distributed Tensorflow on each actor.
|
||||
|
||||
For 1, you can set a predefined training loop in __init__
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
class MyDataParallelTrainer(DataParallelTrainer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
predefined_train_loop_per_worker = lambda: 1
|
||||
super().__init__(predefined_train_loop_per_worker, *args, **kwargs)
|
||||
|
||||
|
||||
For 2, you can implement the ``ray.train.Backend`` and ``ray.train.BackendConfig``
|
||||
interfaces.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from dataclasses import dataclass
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
|
||||
class MyBackend(Backend):
|
||||
def on_start(self, worker_group, backend_config):
|
||||
def set_env_var(env_var_value):
|
||||
import os
|
||||
os.environ["MY_ENV_VAR"] = env_var_value
|
||||
|
||||
worker_group.execute(set_env_var, backend_config.env_var)
|
||||
|
||||
@dataclass
|
||||
class MyBackendConfig(BackendConfig):
|
||||
env_var: str = "default_value"
|
||||
|
||||
def backend_cls(self):
|
||||
return MyBackend
|
||||
|
||||
class MyTrainer(DataParallelTrainer):
|
||||
def __init__(self, train_loop_per_worker, my_backend_config:
|
||||
MyBackendConfig, **kwargs):
|
||||
|
||||
super().__init__(
|
||||
train_loop_per_worker,
|
||||
backend_config=my_backend_config, **kwargs)
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute.
|
||||
This can either take in no arguments or a ``config`` dict.
|
||||
train_loop_config: Configurations to pass into
|
||||
``train_loop_per_worker`` if it accepts an argument.
|
||||
backend_config: Configuration for setting up a Backend (e.g. Torch,
|
||||
Tensorflow, Horovod) on each worker to enable distributed
|
||||
communication. If no Backend should be set up, then set this to None.
|
||||
scaling_config: Configuration for how to scale data parallel training.
|
||||
dataset_config: Configuration for dataset ingest. This is merged with the
|
||||
default dataset config for the given trainer (`cls._dataset_config`).
|
||||
run_config: Configuration for the execution of the training run.
|
||||
datasets: Ray Datasets to use for training and evaluation.
|
||||
This is a dict where the key is the name of the dataset, which
|
||||
can be accessed from within the ``train_loop_per_worker`` by calling
|
||||
``train.get_dataset_shard(dataset_key)``.
|
||||
By default, all datasets are sharded equally across workers.
|
||||
This can be configured via ``dataset_config``.
|
||||
metadata: Dict that should be made available via
|
||||
`train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
"""
|
||||
|
||||
# Exposed here for testing purposes. Should never need
|
||||
# to be overridden.
|
||||
_backend_executor_cls: Type[BackendExecutor] = BackendExecutor
|
||||
_training_iterator_cls: Type[TrainingIterator] = TrainingIterator
|
||||
|
||||
_scaling_config_allowed_keys = BaseTrainer._scaling_config_allowed_keys + [
|
||||
"num_workers",
|
||||
"resources_per_worker",
|
||||
"use_gpu",
|
||||
"placement_strategy",
|
||||
"accelerator_type",
|
||||
]
|
||||
|
||||
# For backwards compatibility with the legacy dataset config API.
|
||||
_dataset_config = None
|
||||
|
||||
_fields_for_tuner_param_space = BaseTrainer._fields_for_tuner_param_space + [
|
||||
"train_loop_config"
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Union[Callable[[], None], Callable[[Dict], None]],
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
backend_config: Optional[BackendConfig] = None,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
dataset_config: Optional[DataConfig] = None,
|
||||
run_config: Optional[RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
self._train_loop_per_worker = train_loop_per_worker
|
||||
self._train_loop_config = train_loop_config
|
||||
|
||||
if dataset_config is None:
|
||||
dataset_config = DataConfig()
|
||||
|
||||
if not isinstance(dataset_config, DataConfig):
|
||||
raise ValueError(
|
||||
"`dataset_config` must be an instance of ray.train.DataConfig, "
|
||||
f"was: {dataset_config}"
|
||||
)
|
||||
self._data_config = dataset_config
|
||||
|
||||
backend_config = (
|
||||
backend_config if backend_config is not None else BackendConfig()
|
||||
)
|
||||
self._backend_config = backend_config
|
||||
|
||||
super(DataParallelTrainer, self).__init__(
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
metadata=metadata,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
)
|
||||
|
||||
train_total_resources = self.scaling_config.total_resources
|
||||
self._data_config.set_train_total_resources(
|
||||
train_total_resources.get("CPU", 0),
|
||||
train_total_resources.get("GPU", 0),
|
||||
)
|
||||
|
||||
if env_integer(RAY_TRAIN_ENABLE_STATE_TRACKING, 0):
|
||||
from ray.train._internal.state.state_actor import get_or_create_state_actor
|
||||
|
||||
get_or_create_state_actor()
|
||||
|
||||
@classmethod
|
||||
@Deprecated(message=_TRAINER_RESTORE_DEPRECATION_WARNING)
|
||||
def restore(
|
||||
cls,
|
||||
path: str,
|
||||
train_loop_per_worker: Optional[
|
||||
Union[Callable[[], None], Callable[[Dict], None]]
|
||||
] = None,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Restores a DataParallelTrainer from a previously interrupted/failed run.
|
||||
|
||||
Args:
|
||||
path: The path to the experiment directory to restore from.
|
||||
train_loop_per_worker: Optionally re-specified train loop function.
|
||||
This should be used to re-specify a function that is not
|
||||
restorable in a new Ray cluster (e.g., it holds onto outdated
|
||||
object references). This should be the same training loop
|
||||
that was passed to the original trainer constructor.
|
||||
train_loop_config: Optionally re-specified train config.
|
||||
This should similarly be used if the original `train_loop_config`
|
||||
contained outdated object references, and it should not be modified
|
||||
from what was originally passed in.
|
||||
**kwargs: Additional arguments forwarded to
|
||||
:meth:`BaseTrainer.restore() <ray.train.trainer.BaseTrainer.restore>`.
|
||||
|
||||
See :meth:`BaseTrainer.restore() <ray.train.trainer.BaseTrainer.restore>`
|
||||
for descriptions of the other arguments.
|
||||
|
||||
Returns:
|
||||
A restored instance of the ``DataParallelTrainer``.
|
||||
"""
|
||||
return super(DataParallelTrainer, cls).restore(
|
||||
path=path,
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _validate_attributes(self):
|
||||
super()._validate_attributes()
|
||||
|
||||
self._validate_train_loop_per_worker(
|
||||
self._train_loop_per_worker, "train_loop_per_worker"
|
||||
)
|
||||
|
||||
def _validate_train_loop_per_worker(
|
||||
self, train_loop_per_worker: Callable, fn_name: str
|
||||
) -> None:
|
||||
num_required_params = count_required_parameters(train_loop_per_worker)
|
||||
if num_required_params > 1:
|
||||
raise ValueError(
|
||||
f"{fn_name} should take in 0 or 1 arguments, "
|
||||
f"but it accepts {num_required_params} arguments instead."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_scaling_config(cls, scaling_config: ScalingConfig) -> ScalingConfig:
|
||||
scaling_config = super(DataParallelTrainer, cls)._validate_scaling_config(
|
||||
scaling_config
|
||||
)
|
||||
|
||||
# This validation happens after the scaling config is updated from
|
||||
# its specification in the Tuner `param_space`
|
||||
if not scaling_config.use_gpu and "GPU" in ray.available_resources():
|
||||
logger.info(
|
||||
"GPUs are detected in your Ray cluster, but GPU "
|
||||
"training is not enabled for this trainer. To enable "
|
||||
"GPU training, make sure to set `use_gpu` to True "
|
||||
"in your scaling config."
|
||||
)
|
||||
|
||||
if scaling_config.num_workers is None:
|
||||
raise ValueError(
|
||||
"You must specify the 'num_workers' in `scaling_config` as either an "
|
||||
f"argument of `{cls.__name__}` or through the `param_space` of a "
|
||||
"`Tuner` (if performing hyperparameter tuning)."
|
||||
)
|
||||
|
||||
if scaling_config.num_workers <= 0:
|
||||
raise ValueError(
|
||||
"'num_workers' in `scaling_config` must be a positive "
|
||||
f"integer. Received {scaling_config.num_workers}"
|
||||
)
|
||||
|
||||
return scaling_config
|
||||
|
||||
def _run_training(self, training_iterator: TrainingIterator) -> None:
|
||||
"""This method loops over the `TrainingIterator`:
|
||||
The actual iteration (for ... in ...) waits for the training function
|
||||
on each worker to report a result and supplies it as a list of results.
|
||||
Afterwards (in the body of the loop), it will report the result
|
||||
to the Tune session.
|
||||
The iterator ends after the training function on each worker has finished.
|
||||
"""
|
||||
for training_results in training_iterator:
|
||||
# TODO(ml-team): add ability to report results from multiple workers.
|
||||
self._propagate_results(training_results)
|
||||
|
||||
def _propagate_results(self, training_results: List[_TrainingResult]):
|
||||
first_worker_result = training_results[0]
|
||||
assert all(isinstance(result, _TrainingResult) for result in training_results)
|
||||
|
||||
tune_session = get_session()
|
||||
|
||||
# Check if any workers reported a checkpoint.
|
||||
# If so, report a checkpoint pointing to the persisted location
|
||||
# to Tune for book-keeping.
|
||||
# NOTE: This removes the restriction for any individual worker
|
||||
# (ex: global rank 0 worker) from needing to report a checkpoint.
|
||||
# All workers reported a checkpoint to the same fs path, so there's
|
||||
# no need to report multiple checkpoints to Tune.
|
||||
worker_checkpoints = [
|
||||
result.checkpoint
|
||||
for result in training_results
|
||||
if result.checkpoint is not None
|
||||
]
|
||||
at_least_one_reported_checkpoint = len(worker_checkpoints) > 0
|
||||
|
||||
if at_least_one_reported_checkpoint:
|
||||
# Update the coordinator's checkpoint index to the latest.
|
||||
# This is what keeps the checkpoint index in line with the workers.
|
||||
tune_session.storage._update_checkpoint_index(first_worker_result.metrics)
|
||||
|
||||
# Make sure that all workers uploaded to the same location.
|
||||
assert all(
|
||||
checkpoint.path == tune_session.storage.checkpoint_fs_path
|
||||
for checkpoint in worker_checkpoints
|
||||
)
|
||||
|
||||
checkpoint = (
|
||||
Checkpoint(
|
||||
filesystem=tune_session.storage.storage_filesystem,
|
||||
path=tune_session.storage.checkpoint_fs_path,
|
||||
)
|
||||
if at_least_one_reported_checkpoint
|
||||
else None
|
||||
)
|
||||
|
||||
tracked_training_result = _TrainingResult(
|
||||
checkpoint=checkpoint,
|
||||
metrics=first_worker_result.metrics,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Report (metrics, checkpoint) to the Tune session:\n"
|
||||
f" metrics={tracked_training_result.metrics}\n"
|
||||
f" checkpoint={tracked_training_result.checkpoint}"
|
||||
)
|
||||
|
||||
# Report the metrics and checkpoint to Tune.
|
||||
tune_session._report_training_result(tracked_training_result)
|
||||
|
||||
def training_loop(self) -> None:
|
||||
scaling_config = self._validate_scaling_config(self.scaling_config)
|
||||
|
||||
train_loop_per_worker = construct_train_func(
|
||||
self._train_loop_per_worker,
|
||||
self._train_loop_config,
|
||||
train_func_context=self._backend_config.train_func_context,
|
||||
fn_arg_name="train_loop_per_worker",
|
||||
discard_returns=True,
|
||||
)
|
||||
|
||||
trial_info = TrialInfo(
|
||||
name=session.get_trial_name(),
|
||||
id=session.get_trial_id(),
|
||||
resources=session.get_trial_resources(),
|
||||
logdir=session.get_trial_dir(),
|
||||
driver_ip=ray.util.get_node_ip_address(),
|
||||
driver_node_id=ray.get_runtime_context().get_node_id(),
|
||||
experiment_name=session.get_experiment_name(),
|
||||
run_id=uuid.uuid4().hex,
|
||||
)
|
||||
|
||||
backend_executor = self._backend_executor_cls(
|
||||
backend_config=self._backend_config,
|
||||
trial_info=trial_info,
|
||||
num_workers=scaling_config.num_workers,
|
||||
resources_per_worker=scaling_config._resources_per_worker_not_none,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
# Start the remote actors.
|
||||
backend_executor.start()
|
||||
|
||||
training_iterator = self._training_iterator_cls(
|
||||
backend_executor=backend_executor,
|
||||
backend_config=self._backend_config,
|
||||
train_func=train_loop_per_worker,
|
||||
datasets=self.datasets,
|
||||
metadata=self.metadata,
|
||||
data_config=self._data_config,
|
||||
checkpoint=self.starting_checkpoint,
|
||||
)
|
||||
|
||||
self._run_training(training_iterator)
|
||||
|
||||
# Shutdown workers.
|
||||
backend_executor.shutdown()
|
||||
|
||||
def get_dataset_config(self) -> DataConfig:
|
||||
"""Returns a copy of this Trainer's final dataset configs.
|
||||
|
||||
Returns:
|
||||
The merged default + user-supplied dataset config.
|
||||
"""
|
||||
|
||||
return self._data_config
|
||||
|
||||
@repr_with_fallback(["ipywidgets", "8"])
|
||||
def _repr_mimebundle_(self, **kwargs: Any):
|
||||
"""Returns a mimebundle with an ipywidget repr and a simple text repr.
|
||||
|
||||
Depending on the frontend where the data is being displayed,
|
||||
different mimetypes will be used from this bundle.
|
||||
See https://ipython.readthedocs.io/en/stable/config/integrating.html
|
||||
for information about this method, and
|
||||
https://ipywidgets.readthedocs.io/en/latest/embedding.html
|
||||
for more information about the jupyter widget mimetype.
|
||||
|
||||
Args:
|
||||
**kwargs: Standard Jupyter mimebundle kwargs (e.g. ``include``,
|
||||
``exclude``); unused by this implementation.
|
||||
|
||||
Returns:
|
||||
A mimebundle containing an ipywidget repr and a simple text repr.
|
||||
"""
|
||||
from ipywidgets import HTML, Layout, Tab, VBox
|
||||
|
||||
title = HTML(f"<h2>{self.__class__.__name__}</h2>")
|
||||
|
||||
children = []
|
||||
titles = []
|
||||
|
||||
if self.datasets:
|
||||
children.append(self._datasets_repr_())
|
||||
titles.append("Datasets")
|
||||
|
||||
children.append(HTML(self._data_config_repr_html_()))
|
||||
titles.append("Data Config")
|
||||
|
||||
if self._train_loop_config:
|
||||
children.append(HTML(self._train_loop_config_repr_html_()))
|
||||
titles.append("Train Loop Config")
|
||||
|
||||
if self.scaling_config:
|
||||
children.append(HTML(self.scaling_config._repr_html_()))
|
||||
titles.append("Scaling Config")
|
||||
|
||||
if self.run_config:
|
||||
children.append(HTML(self.run_config._repr_html_()))
|
||||
titles.append("Run Config")
|
||||
|
||||
if self._backend_config:
|
||||
children.append(HTML(self._backend_config._repr_html_()))
|
||||
titles.append("Backend Config")
|
||||
|
||||
tab = Tab(children, titles=titles)
|
||||
widget = VBox([title, tab], layout=Layout(width="100%"))
|
||||
bundle = widget._repr_mimebundle_(**kwargs)
|
||||
bundle.update(
|
||||
{
|
||||
"text/plain": repr(self),
|
||||
}
|
||||
)
|
||||
return bundle
|
||||
|
||||
def _train_loop_config_repr_html_(self) -> str:
|
||||
if self._train_loop_config:
|
||||
table_data = {}
|
||||
for k, v in self._train_loop_config.items():
|
||||
if isinstance(v, str) or str(v).isnumeric():
|
||||
table_data[k] = v
|
||||
elif hasattr(v, "_repr_html_"):
|
||||
table_data[k] = v._repr_html_()
|
||||
else:
|
||||
table_data[k] = str(v)
|
||||
|
||||
return Template("title_data.html.j2").render(
|
||||
title="Train Loop Config",
|
||||
data=Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
table_data.items(),
|
||||
headers=["Setting", "Value"],
|
||||
showindex=False,
|
||||
tablefmt="unsafehtml",
|
||||
),
|
||||
max_height="none",
|
||||
),
|
||||
)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def _data_config_repr_html_(self) -> str:
|
||||
# TODO make this rendering nicer.
|
||||
content = [str(self._data_config)]
|
||||
return Template("rendered_html_common.html.j2").render(content=content)
|
||||
|
||||
def _datasets_repr_(self) -> str:
|
||||
from ipywidgets import HTML, Layout, VBox
|
||||
|
||||
content = []
|
||||
if self.datasets:
|
||||
for name, config in self.datasets.items():
|
||||
tab = config._tab_repr_()
|
||||
if tab:
|
||||
content.append(
|
||||
HTML(
|
||||
Template("title_data.html.j2").render(
|
||||
title=f"Dataset - <code>{name}</code>", data=None
|
||||
)
|
||||
)
|
||||
)
|
||||
content.append(config._tab_repr_())
|
||||
|
||||
return VBox(content, layout=Layout(width="100%"))
|
||||
@@ -0,0 +1,6 @@
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class SessionMisuseError(Exception):
|
||||
"""Indicates a method or function was used outside of a session."""
|
||||
@@ -0,0 +1,164 @@
|
||||
# __accelerate_torch_basic_example_start__
|
||||
"""
|
||||
Minimal Ray Train and Accelerate example adapted from
|
||||
https://github.com/huggingface/accelerate/blob/main/examples/nlp_example.py
|
||||
|
||||
Fine-tune a BERT model with Hugging Face Accelerate and Ray Train and Ray Data
|
||||
"""
|
||||
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import evaluate
|
||||
import torch
|
||||
from accelerate import Accelerator
|
||||
from datasets import load_dataset
|
||||
from torch.optim import AdamW
|
||||
from transformers import (
|
||||
AutoModelForSequenceClassification,
|
||||
AutoTokenizer,
|
||||
get_linear_schedule_with_warmup,
|
||||
set_seed,
|
||||
)
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
from ray.train import Checkpoint, DataConfig, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def train_func(config):
|
||||
"""Your training function that launches on each worker."""
|
||||
|
||||
# Unpack training configs
|
||||
lr = config["lr"]
|
||||
seed = config["seed"]
|
||||
num_epochs = config["num_epochs"]
|
||||
train_batch_size = config["train_batch_size"]
|
||||
eval_batch_size = config["eval_batch_size"]
|
||||
train_ds_size = config["train_dataset_size"]
|
||||
|
||||
set_seed(seed)
|
||||
|
||||
# Initialize accelerator
|
||||
accelerator = Accelerator()
|
||||
|
||||
# Load datasets and metrics
|
||||
metric = evaluate.load("glue", "mrpc")
|
||||
|
||||
# Prepare Ray Data loaders
|
||||
# ====================================================
|
||||
train_ds = ray.train.get_dataset_shard("train")
|
||||
eval_ds = ray.train.get_dataset_shard("validation")
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def collate_fn(batch):
|
||||
outputs = tokenizer(
|
||||
list(batch["sentence1"]),
|
||||
list(batch["sentence2"]),
|
||||
truncation=True,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
)
|
||||
outputs["labels"] = torch.LongTensor(batch["label"])
|
||||
outputs = {k: v.to(accelerator.device) for k, v in outputs.items()}
|
||||
return outputs
|
||||
|
||||
train_dataloader = train_ds.iter_torch_batches(
|
||||
batch_size=train_batch_size, collate_fn=collate_fn
|
||||
)
|
||||
eval_dataloader = eval_ds.iter_torch_batches(
|
||||
batch_size=eval_batch_size, collate_fn=collate_fn
|
||||
)
|
||||
# ====================================================
|
||||
|
||||
# Instantiate the model, optimizer, lr_scheduler
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", return_dict=True
|
||||
)
|
||||
|
||||
optimizer = AdamW(params=model.parameters(), lr=lr)
|
||||
|
||||
steps_per_epoch = train_ds_size // (accelerator.num_processes * train_batch_size)
|
||||
lr_scheduler = get_linear_schedule_with_warmup(
|
||||
optimizer=optimizer,
|
||||
num_warmup_steps=100,
|
||||
num_training_steps=(steps_per_epoch * num_epochs),
|
||||
)
|
||||
|
||||
# Prepare everything with accelerator
|
||||
model, optimizer, lr_scheduler = accelerator.prepare(model, optimizer, lr_scheduler)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
# Training
|
||||
model.train()
|
||||
for batch in train_dataloader:
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss
|
||||
accelerator.backward(loss)
|
||||
optimizer.step()
|
||||
lr_scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
# Evaluation
|
||||
model.eval()
|
||||
for batch in eval_dataloader:
|
||||
with torch.no_grad():
|
||||
outputs = model(**batch)
|
||||
predictions = outputs.logits.argmax(dim=-1)
|
||||
|
||||
predictions, references = accelerator.gather_for_metrics(
|
||||
(predictions, batch["labels"])
|
||||
)
|
||||
metric.add_batch(
|
||||
predictions=predictions,
|
||||
references=references,
|
||||
)
|
||||
|
||||
eval_metric = metric.compute()
|
||||
accelerator.print(f"epoch {epoch}:", eval_metric)
|
||||
|
||||
# Report checkpoint and metrics to Ray Train
|
||||
# ==========================================
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
if accelerator.is_main_process:
|
||||
unwrapped_model = accelerator.unwrap_model(model)
|
||||
accelerator.save(unwrapped_model, f"{tmpdir}/ckpt_{epoch}.bin")
|
||||
checkpoint = Checkpoint.from_directory(tmpdir)
|
||||
else:
|
||||
checkpoint = None
|
||||
ray.train.report(metrics=eval_metric, checkpoint=checkpoint)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {
|
||||
"lr": 2e-5,
|
||||
"num_epochs": 3,
|
||||
"seed": 42,
|
||||
"train_batch_size": 16,
|
||||
"eval_batch_size": 32,
|
||||
}
|
||||
|
||||
# Prepare Ray Datasets
|
||||
hf_datasets = load_dataset("nyu-mll/glue", "mrpc")
|
||||
ray_datasets = {
|
||||
"train": ray.data.from_huggingface(hf_datasets["train"]),
|
||||
"validation": ray.data.from_huggingface(hf_datasets["validation"]),
|
||||
}
|
||||
config["train_dataset_size"] = ray_datasets["train"].count()
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=config,
|
||||
datasets=ray_datasets,
|
||||
dataset_config=DataConfig(datasets_to_split=["train", "validation"]),
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
# If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
# run_config=ray.train.RunConfig(storage_path="s3://..."),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
# __accelerate_torch_basic_example_end__
|
||||
@@ -0,0 +1,169 @@
|
||||
# __accelerate_torch_basic_example_no_raydata_start__
|
||||
"""
|
||||
Minimal Ray Train + Accelerate example adapted from
|
||||
https://github.com/huggingface/accelerate/blob/main/examples/nlp_example.py
|
||||
|
||||
Fine-tune a BERT model with Hugging Face Accelerate and Ray Train
|
||||
"""
|
||||
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import evaluate
|
||||
import torch
|
||||
from accelerate import Accelerator
|
||||
from datasets import load_dataset
|
||||
from torch.optim import AdamW
|
||||
from torch.utils.data import DataLoader
|
||||
from transformers import (
|
||||
AutoModelForSequenceClassification,
|
||||
AutoTokenizer,
|
||||
get_linear_schedule_with_warmup,
|
||||
set_seed,
|
||||
)
|
||||
|
||||
import ray.train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def train_func(config):
|
||||
"""Your training function that will be launched on each worker."""
|
||||
|
||||
# Unpack training configs
|
||||
lr = config["lr"]
|
||||
seed = config["seed"]
|
||||
num_epochs = config["num_epochs"]
|
||||
train_batch_size = config["train_batch_size"]
|
||||
eval_batch_size = config["eval_batch_size"]
|
||||
|
||||
set_seed(seed)
|
||||
|
||||
# Initialize accelerator
|
||||
accelerator = Accelerator()
|
||||
|
||||
# Load datasets and metrics
|
||||
metric = evaluate.load("glue", "mrpc")
|
||||
|
||||
# Prepare PyTorch DataLoaders
|
||||
# ====================================================
|
||||
hf_datasets = load_dataset("nyu-mll/glue", "mrpc")
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def collate_fn(batch):
|
||||
outputs = tokenizer(
|
||||
[sample["sentence1"] for sample in batch],
|
||||
[sample["sentence2"] for sample in batch],
|
||||
truncation=True,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
)
|
||||
outputs["labels"] = torch.LongTensor([sample["label"] for sample in batch])
|
||||
outputs = {k: v.to(accelerator.device) for k, v in outputs.items()}
|
||||
return outputs
|
||||
|
||||
# Instantiate dataloaders.
|
||||
train_dataloader = DataLoader(
|
||||
hf_datasets["train"],
|
||||
shuffle=True,
|
||||
collate_fn=collate_fn,
|
||||
batch_size=train_batch_size,
|
||||
drop_last=True,
|
||||
)
|
||||
eval_dataloader = DataLoader(
|
||||
hf_datasets["validation"],
|
||||
shuffle=False,
|
||||
collate_fn=collate_fn,
|
||||
batch_size=eval_batch_size,
|
||||
drop_last=True,
|
||||
)
|
||||
# ====================================================
|
||||
|
||||
# Instantiate the model, optimizer, lr_scheduler
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", return_dict=True
|
||||
)
|
||||
|
||||
optimizer = AdamW(params=model.parameters(), lr=lr)
|
||||
|
||||
steps_per_epoch = len(train_dataloader)
|
||||
lr_scheduler = get_linear_schedule_with_warmup(
|
||||
optimizer=optimizer,
|
||||
num_warmup_steps=100,
|
||||
num_training_steps=(steps_per_epoch * num_epochs),
|
||||
)
|
||||
|
||||
# Prepare everything with accelerator
|
||||
(
|
||||
model,
|
||||
optimizer,
|
||||
train_dataloader,
|
||||
eval_dataloader,
|
||||
lr_scheduler,
|
||||
) = accelerator.prepare(
|
||||
model, optimizer, train_dataloader, eval_dataloader, lr_scheduler
|
||||
)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
# Training
|
||||
model.train()
|
||||
for batch in train_dataloader:
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss
|
||||
accelerator.backward(loss)
|
||||
optimizer.step()
|
||||
lr_scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
# Evaluation
|
||||
model.eval()
|
||||
for batch in eval_dataloader:
|
||||
with torch.no_grad():
|
||||
outputs = model(**batch)
|
||||
predictions = outputs.logits.argmax(dim=-1)
|
||||
|
||||
predictions, references = accelerator.gather_for_metrics(
|
||||
(predictions, batch["labels"])
|
||||
)
|
||||
metric.add_batch(
|
||||
predictions=predictions,
|
||||
references=references,
|
||||
)
|
||||
|
||||
eval_metric = metric.compute()
|
||||
accelerator.print(f"epoch {epoch}:", eval_metric)
|
||||
|
||||
# Report Checkpoint and metrics to Ray Train
|
||||
# ==========================================
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
if accelerator.is_main_process:
|
||||
unwrapped_model = accelerator.unwrap_model(model)
|
||||
accelerator.save(unwrapped_model, f"{tmpdir}/ckpt_{epoch}.bin")
|
||||
checkpoint = Checkpoint.from_directory(tmpdir)
|
||||
else:
|
||||
checkpoint = None
|
||||
ray.train.report(metrics=eval_metric, checkpoint=checkpoint)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {
|
||||
"lr": 2e-5,
|
||||
"num_epochs": 3,
|
||||
"seed": 42,
|
||||
"train_batch_size": 16,
|
||||
"eval_batch_size": 32,
|
||||
}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
# If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
# run_config=ray.train.RunConfig(storage_path="s3://..."),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
# __accelerate_torch_basic_example_no_raydata_end__
|
||||
@@ -0,0 +1,185 @@
|
||||
# __deepspeed_torch_basic_example_start__
|
||||
"""
|
||||
Minimal Ray Train + DeepSpeed example adapted from
|
||||
https://github.com/huggingface/accelerate/blob/main/examples/nlp_example.py
|
||||
|
||||
Fine-tune a BERT model with DeepSpeed ZeRO-3 and Ray Train and Ray Data
|
||||
"""
|
||||
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import deepspeed
|
||||
import torch
|
||||
from datasets import load_dataset
|
||||
from deepspeed.accelerator import get_accelerator
|
||||
from torchmetrics.classification import BinaryAccuracy, BinaryF1Score
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer, set_seed
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
from ray.train import Checkpoint, DataConfig, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def train_func(config):
|
||||
"""Your training function that will be launched on each worker."""
|
||||
|
||||
# Unpack training configs
|
||||
set_seed(config["seed"])
|
||||
num_epochs = config["num_epochs"]
|
||||
train_batch_size = config["train_batch_size"]
|
||||
eval_batch_size = config["eval_batch_size"]
|
||||
|
||||
# Instantiate the Model
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", return_dict=True
|
||||
)
|
||||
|
||||
# Prepare Ray Data Loaders
|
||||
# ====================================================
|
||||
train_ds = ray.train.get_dataset_shard("train")
|
||||
eval_ds = ray.train.get_dataset_shard("validation")
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def collate_fn(batch):
|
||||
outputs = tokenizer(
|
||||
list(batch["sentence1"]),
|
||||
list(batch["sentence2"]),
|
||||
truncation=True,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
)
|
||||
outputs["labels"] = torch.LongTensor(batch["label"])
|
||||
return outputs
|
||||
|
||||
train_dataloader = train_ds.iter_torch_batches(
|
||||
batch_size=train_batch_size, collate_fn=collate_fn
|
||||
)
|
||||
eval_dataloader = eval_ds.iter_torch_batches(
|
||||
batch_size=eval_batch_size, collate_fn=collate_fn
|
||||
)
|
||||
# ====================================================
|
||||
|
||||
# Initialize DeepSpeed Engine
|
||||
model, optimizer, _, lr_scheduler = deepspeed.initialize(
|
||||
model=model,
|
||||
model_parameters=model.parameters(),
|
||||
config=deepspeed_config,
|
||||
)
|
||||
device = get_accelerator().device_name(model.local_rank)
|
||||
|
||||
# Initialize Evaluation Metrics
|
||||
f1 = BinaryF1Score().to(device)
|
||||
accuracy = BinaryAccuracy().to(device)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
# Training
|
||||
model.train()
|
||||
for batch in train_dataloader:
|
||||
batch = {k: v.to(device) for k, v in batch.items()}
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss
|
||||
model.backward(loss)
|
||||
optimizer.step()
|
||||
lr_scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
# Evaluation
|
||||
model.eval()
|
||||
for batch in eval_dataloader:
|
||||
batch = {k: v.to(device) for k, v in batch.items()}
|
||||
with torch.no_grad():
|
||||
outputs = model(**batch)
|
||||
predictions = outputs.logits.argmax(dim=-1)
|
||||
|
||||
f1.update(predictions, batch["labels"])
|
||||
accuracy.update(predictions, batch["labels"])
|
||||
|
||||
# torchmetrics will aggregate the metrics across all workers
|
||||
eval_metric = {
|
||||
"f1": f1.compute().item(),
|
||||
"accuracy": accuracy.compute().item(),
|
||||
}
|
||||
f1.reset()
|
||||
accuracy.reset()
|
||||
|
||||
if model.global_rank == 0:
|
||||
print(f"epoch {epoch}:", eval_metric)
|
||||
|
||||
# Report checkpoint and metrics to Ray Train
|
||||
# ==============================================================
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
# Each worker saves its own checkpoint shard
|
||||
model.save_checkpoint(tmpdir)
|
||||
|
||||
# Ensure all workers finished saving their checkpoint shard
|
||||
torch.distributed.barrier()
|
||||
|
||||
# Report checkpoint shards from each worker in parallel
|
||||
ray.train.report(
|
||||
metrics=eval_metric, checkpoint=Checkpoint.from_directory(tmpdir)
|
||||
)
|
||||
# ==============================================================
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deepspeed_config = {
|
||||
"optimizer": {
|
||||
"type": "AdamW",
|
||||
"params": {
|
||||
"lr": 2e-5,
|
||||
},
|
||||
},
|
||||
"scheduler": {"type": "WarmupLR", "params": {"warmup_num_steps": 100}},
|
||||
"fp16": {"enabled": True},
|
||||
"bf16": {"enabled": False}, # Turn this on if using AMPERE GPUs.
|
||||
"zero_optimization": {
|
||||
"stage": 3,
|
||||
"offload_optimizer": {
|
||||
"device": "none",
|
||||
},
|
||||
"offload_param": {
|
||||
"device": "none",
|
||||
},
|
||||
},
|
||||
"gradient_accumulation_steps": 1,
|
||||
"gradient_clipping": True,
|
||||
"steps_per_print": 10,
|
||||
"train_micro_batch_size_per_gpu": 16,
|
||||
"wall_clock_breakdown": False,
|
||||
}
|
||||
|
||||
training_config = {
|
||||
"seed": 42,
|
||||
"num_epochs": 3,
|
||||
"train_batch_size": 16,
|
||||
"eval_batch_size": 32,
|
||||
"deepspeed_config": deepspeed_config,
|
||||
}
|
||||
|
||||
# Prepare Ray Datasets
|
||||
hf_datasets = load_dataset("nyu-mll/glue", "mrpc")
|
||||
ray_datasets = {
|
||||
"train": ray.data.from_huggingface(hf_datasets["train"]),
|
||||
"validation": ray.data.from_huggingface(hf_datasets["validation"]),
|
||||
}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=training_config,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
datasets=ray_datasets,
|
||||
dataset_config=DataConfig(datasets_to_split=["train", "validation"]),
|
||||
# If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
# run_config=ray.train.RunConfig(storage_path="s3://..."),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
# Retrieve the best checkponints from results
|
||||
_ = result.best_checkpoints
|
||||
|
||||
# __deepspeed_torch_basic_example_end__
|
||||
@@ -0,0 +1,178 @@
|
||||
# __deepspeed_torch_basic_example_no_raydata_start__
|
||||
"""
|
||||
Minimal Ray Train + DeepSpeed example adapted from
|
||||
https://github.com/huggingface/accelerate/blob/main/examples/nlp_example.py
|
||||
|
||||
Fine-tune a BERT model with DeepSpeed ZeRO-3 and Ray Train
|
||||
"""
|
||||
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import deepspeed
|
||||
import torch
|
||||
from datasets import load_dataset
|
||||
from deepspeed.accelerator import get_accelerator
|
||||
from torch.utils.data import DataLoader
|
||||
from torchmetrics.classification import BinaryAccuracy, BinaryF1Score
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer, set_seed
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def train_func(config):
|
||||
"""Your training function that will be launched on each worker."""
|
||||
|
||||
# Unpack training configs
|
||||
set_seed(config["seed"])
|
||||
num_epochs = config["num_epochs"]
|
||||
eval_batch_size = config["eval_batch_size"]
|
||||
|
||||
# Instantiate the Model
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", return_dict=True
|
||||
)
|
||||
|
||||
# Prepare PyTorch Data Loaders
|
||||
# ====================================================
|
||||
hf_datasets = load_dataset("nyu-mll/glue", "mrpc")
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def collate_fn(batch):
|
||||
outputs = tokenizer(
|
||||
[sample["sentence1"] for sample in batch],
|
||||
[sample["sentence2"] for sample in batch],
|
||||
truncation=True,
|
||||
padding="longest",
|
||||
return_tensors="pt",
|
||||
)
|
||||
outputs["labels"] = torch.LongTensor([sample["label"] for sample in batch])
|
||||
return outputs
|
||||
|
||||
# Instantiate dataloaders.
|
||||
# The train_dataloader already created by `deepspeed.initialize`
|
||||
eval_dataloader = DataLoader(
|
||||
hf_datasets["validation"],
|
||||
shuffle=False,
|
||||
collate_fn=collate_fn,
|
||||
batch_size=eval_batch_size,
|
||||
drop_last=True,
|
||||
)
|
||||
# ====================================================
|
||||
|
||||
# Initialize DeepSpeed Engine
|
||||
model, optimizer, train_dataloader, lr_scheduler = deepspeed.initialize(
|
||||
model=model,
|
||||
model_parameters=model.parameters(),
|
||||
training_data=hf_datasets["train"],
|
||||
collate_fn=collate_fn,
|
||||
config=deepspeed_config,
|
||||
)
|
||||
device = get_accelerator().device_name(model.local_rank)
|
||||
|
||||
# Initialize Evaluation Metrics
|
||||
f1 = BinaryF1Score().to(device)
|
||||
accuracy = BinaryAccuracy().to(device)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
# Training
|
||||
model.train()
|
||||
for batch in train_dataloader:
|
||||
batch = {k: v.to(device) for k, v in batch.items()}
|
||||
outputs = model(**batch)
|
||||
loss = outputs.loss
|
||||
model.backward(loss)
|
||||
optimizer.step()
|
||||
lr_scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
# Evaluation
|
||||
model.eval()
|
||||
for batch in eval_dataloader:
|
||||
batch = {k: v.to(device) for k, v in batch.items()}
|
||||
with torch.no_grad():
|
||||
outputs = model(**batch)
|
||||
predictions = outputs.logits.argmax(dim=-1)
|
||||
|
||||
f1.update(predictions, batch["labels"])
|
||||
accuracy.update(predictions, batch["labels"])
|
||||
|
||||
# torchmetrics will aggregate the metrics across all workers
|
||||
eval_metric = {
|
||||
"f1": f1.compute().item(),
|
||||
"accuracy": accuracy.compute().item(),
|
||||
}
|
||||
f1.reset()
|
||||
accuracy.reset()
|
||||
|
||||
if model.global_rank == 0:
|
||||
print(f"epoch {epoch}:", eval_metric)
|
||||
|
||||
# Report checkpoint and metrics to Ray Train
|
||||
# ==============================================================
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
# Each worker saves its own checkpoint shard
|
||||
model.save_checkpoint(tmpdir)
|
||||
|
||||
# Ensure all workers finished saving their checkpoint shard
|
||||
torch.distributed.barrier()
|
||||
|
||||
# Report checkpoint shards from each worker in parallel
|
||||
ray.train.report(
|
||||
metrics=eval_metric, checkpoint=Checkpoint.from_directory(tmpdir)
|
||||
)
|
||||
# ==============================================================
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deepspeed_config = {
|
||||
"optimizer": {
|
||||
"type": "AdamW",
|
||||
"params": {
|
||||
"lr": 2e-5,
|
||||
},
|
||||
},
|
||||
"scheduler": {"type": "WarmupLR", "params": {"warmup_num_steps": 100}},
|
||||
"fp16": {"enabled": True},
|
||||
"bf16": {"enabled": False}, # Turn this on if using AMPERE GPUs.
|
||||
"zero_optimization": {
|
||||
"stage": 3,
|
||||
"offload_optimizer": {
|
||||
"device": "none",
|
||||
},
|
||||
"offload_param": {
|
||||
"device": "none",
|
||||
},
|
||||
},
|
||||
"gradient_accumulation_steps": 1,
|
||||
"gradient_clipping": True,
|
||||
"steps_per_print": 10,
|
||||
"train_micro_batch_size_per_gpu": 16,
|
||||
"wall_clock_breakdown": False,
|
||||
}
|
||||
|
||||
training_config = {
|
||||
"seed": 42,
|
||||
"num_epochs": 3,
|
||||
"eval_batch_size": 32,
|
||||
"deepspeed_config": deepspeed_config,
|
||||
}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=training_config,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
|
||||
# If running in a multi-node cluster, this is where you
|
||||
# should configure the run's persistent storage that is accessible
|
||||
# across all worker nodes.
|
||||
# run_config=ray.train.RunConfig(storage_path="s3://..."),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
|
||||
# Retrieve the best checkponints from results
|
||||
_ = result.best_checkpoints
|
||||
|
||||
# __deepspeed_torch_basic_example_no_raydata_end__
|
||||
@@ -0,0 +1,41 @@
|
||||
# isort: skip_file
|
||||
from lightning_exp_tracking_model_dl import DummyModel, dataloader
|
||||
|
||||
# __lightning_experiment_tracking_comet_start__
|
||||
import os
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
import lightning.pytorch as pl
|
||||
from lightning.pytorch.loggers import CometLogger
|
||||
|
||||
|
||||
def train_func(config):
|
||||
logger = None
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
logger = CometLogger(api_key=os.environ["COMET_API_KEY"])
|
||||
|
||||
ptl_trainer = pl.Trainer(
|
||||
max_epochs=5,
|
||||
accelerator="cpu",
|
||||
logger=logger,
|
||||
log_every_n_steps=1,
|
||||
)
|
||||
model = DummyModel()
|
||||
ptl_trainer.fit(model, train_dataloaders=dataloader)
|
||||
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=False)
|
||||
|
||||
assert (
|
||||
"COMET_API_KEY" in os.environ
|
||||
), 'Please do COMET_API_KEY="abcde" when running this script.'
|
||||
# This makes sure that all workers have this env var set.
|
||||
ray.init(runtime_env={"env_vars": {"COMET_API_KEY": os.environ["COMET_API_KEY"]}})
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
@@ -0,0 +1,63 @@
|
||||
# ruff: noqa
|
||||
# isort: skip_file
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
os.environ["SHARED_STORAGE_PATH"] = tempdir.name
|
||||
|
||||
from ray.train.examples.experiment_tracking.lightning_exp_tracking_model_dl import (
|
||||
DummyModel,
|
||||
dataloader,
|
||||
)
|
||||
|
||||
|
||||
# __lightning_experiment_tracking_mlflow_start__
|
||||
import os
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
import lightning.pytorch as pl
|
||||
from lightning.pytorch.loggers import MLFlowLogger
|
||||
|
||||
|
||||
def train_func(config):
|
||||
|
||||
save_dir = config["save_dir"]
|
||||
logger = None
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
logger = MLFlowLogger(
|
||||
experiment_name="demo-project",
|
||||
tracking_uri=f"file:{save_dir}",
|
||||
)
|
||||
|
||||
ptl_trainer = pl.Trainer(
|
||||
max_epochs=5,
|
||||
accelerator="cpu",
|
||||
logger=logger,
|
||||
log_every_n_steps=1,
|
||||
)
|
||||
model = DummyModel()
|
||||
ptl_trainer.fit(model, train_dataloaders=dataloader)
|
||||
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=False)
|
||||
|
||||
assert (
|
||||
"SHARED_STORAGE_PATH" in os.environ
|
||||
), "Please do SHARED_STORAGE_PATH=/a/b/c when running this script."
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config={
|
||||
"save_dir": os.path.join(os.environ["SHARED_STORAGE_PATH"], "mlruns")
|
||||
},
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
# __lightning_experiment_tracking_mlflow_end__
|
||||
|
||||
tempdir.cleanup()
|
||||
@@ -0,0 +1,46 @@
|
||||
# ruff: noqa
|
||||
# fmt: off
|
||||
# # isort: skip_file
|
||||
|
||||
|
||||
# __model_dl_start__
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
|
||||
# Create dummy data
|
||||
X = torch.randn(128, 3) # 128 samples, 3 features
|
||||
y = torch.randint(0, 2, (128,)) # 128 binary labels
|
||||
|
||||
# Create a TensorDataset to wrap the data
|
||||
dataset = TensorDataset(X, y)
|
||||
|
||||
# Create a DataLoader to iterate over the dataset
|
||||
batch_size = 8
|
||||
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
|
||||
|
||||
# Define a dummy model
|
||||
class DummyModel(pl.LightningModule):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.layer = torch.nn.Linear(3, 1)
|
||||
|
||||
def forward(self, x):
|
||||
return self.layer(x)
|
||||
|
||||
def training_step(self, batch, batch_idx):
|
||||
x, y = batch
|
||||
y_hat = self(x)
|
||||
loss = F.binary_cross_entropy_with_logits(y_hat.flatten(), y.float())
|
||||
|
||||
# The metrics below will be reported to Loggers
|
||||
self.log("train_loss", loss)
|
||||
self.log_dict({
|
||||
"metric_1": 1 / (batch_idx + 1), "metric_2": batch_idx * 100
|
||||
})
|
||||
return loss
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.Adam(self.parameters(), lr=1e-3)
|
||||
@@ -0,0 +1,60 @@
|
||||
# ruff: noqa
|
||||
# isort: skip_file
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
os.environ["SHARED_STORAGE_PATH"] = tempdir.name
|
||||
|
||||
from ray.train.examples.experiment_tracking.lightning_exp_tracking_model_dl import (
|
||||
DummyModel,
|
||||
dataloader,
|
||||
)
|
||||
|
||||
# __lightning_experiment_tracking_tensorboard_start__
|
||||
import os
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
import lightning.pytorch as pl
|
||||
from lightning.pytorch.loggers import TensorBoardLogger
|
||||
|
||||
|
||||
def train_func(config):
|
||||
|
||||
save_dir = config["save_dir"]
|
||||
logger = None
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
logger = TensorBoardLogger(name="demo-run", save_dir=f"file:{save_dir}")
|
||||
|
||||
ptl_trainer = pl.Trainer(
|
||||
max_epochs=5,
|
||||
accelerator="cpu",
|
||||
logger=logger,
|
||||
log_every_n_steps=1,
|
||||
)
|
||||
model = DummyModel()
|
||||
ptl_trainer.fit(model, train_dataloaders=dataloader)
|
||||
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=False)
|
||||
|
||||
assert (
|
||||
"SHARED_STORAGE_PATH" in os.environ
|
||||
), "Please do SHARED_STORAGE_PATH=/a/b/c when running this script."
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config={
|
||||
"save_dir": os.path.join(os.environ["SHARED_STORAGE_PATH"], "tensorboard")
|
||||
},
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
# __lightning_experiment_tracking_tensorboard_end__
|
||||
|
||||
tempdir.cleanup()
|
||||
@@ -0,0 +1,50 @@
|
||||
# ruff: noqa
|
||||
# fmt: off
|
||||
# # isort: skip_file
|
||||
|
||||
from lightning_exp_tracking_model_dl import DummyModel, dataloader
|
||||
|
||||
# __lightning_experiment_tracking_wandb_start__
|
||||
import os
|
||||
import wandb
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
import lightning.pytorch as pl
|
||||
from lightning.pytorch.loggers import WandbLogger
|
||||
|
||||
|
||||
def train_func(config):
|
||||
logger = None
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
logger = WandbLogger(name="demo-run", project="demo-project")
|
||||
|
||||
ptl_trainer = pl.Trainer(
|
||||
max_epochs=5,
|
||||
accelerator="cpu",
|
||||
logger=logger,
|
||||
log_every_n_steps=1,
|
||||
)
|
||||
model = DummyModel()
|
||||
ptl_trainer.fit(model, train_dataloaders=dataloader)
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
wandb.finish()
|
||||
|
||||
|
||||
scaling_config = ScalingConfig(num_workers=2, use_gpu=False)
|
||||
|
||||
assert (
|
||||
"WANDB_API_KEY" in os.environ
|
||||
), 'Please set WANDB_API_KEY="abcde" when running this script.'
|
||||
|
||||
# This ensures that all workers have this env var set.
|
||||
ray.init(
|
||||
runtime_env={"env_vars": {"WANDB_API_KEY": os.environ["WANDB_API_KEY"]}}
|
||||
)
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
@@ -0,0 +1,85 @@
|
||||
# ruff: noqa
|
||||
# isort: skip_file
|
||||
from filelock import FileLock
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
os.environ["SHARED_STORAGE_PATH"] = tempdir.name
|
||||
|
||||
# __start__
|
||||
# Run the following script with the SHARED_STORAGE_PATH env var set.
|
||||
# The MLflow offline logs are saved to SHARED_STORAGE_PATH/mlruns.
|
||||
|
||||
import mlflow
|
||||
import os
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
import torch
|
||||
from torchvision import datasets, transforms
|
||||
from torchvision.models import resnet18
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
assert os.environ.get(
|
||||
"SHARED_STORAGE_PATH", None
|
||||
), "Please set SHARED_STORAGE_PATH env var."
|
||||
|
||||
|
||||
# Assumes you are passing a `save_dir` in `config`
|
||||
def train_func(config):
|
||||
save_dir = config["save_dir"]
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
mlflow.set_tracking_uri(f"file:{save_dir}")
|
||||
mlflow.set_experiment("my_experiment")
|
||||
mlflow.start_run()
|
||||
|
||||
# Model, Loss, Optimizer
|
||||
model = resnet18(num_classes=10)
|
||||
model.conv1 = torch.nn.Conv2d(
|
||||
1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False
|
||||
)
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
criterion = torch.nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.Adam(model.module.parameters(), lr=0.001)
|
||||
|
||||
# Data
|
||||
transform = transforms.Compose(
|
||||
[transforms.ToTensor(), transforms.Normalize((0.28604,), (0.32025,))]
|
||||
)
|
||||
with FileLock("./data.lock"):
|
||||
train_data = datasets.FashionMNIST(
|
||||
root="./data", train=True, download=True, transform=transform
|
||||
)
|
||||
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
|
||||
train_loader = ray.train.torch.prepare_data_loader(train_loader)
|
||||
|
||||
# Training
|
||||
for epoch in range(1):
|
||||
if ray.train.get_context().get_world_size() > 1:
|
||||
train_loader.sampler.set_epoch(epoch)
|
||||
|
||||
for images, labels in train_loader:
|
||||
outputs = model(images)
|
||||
loss = criterion(outputs, labels)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
mlflow.log_metrics({"loss": loss.item(), "epoch": epoch})
|
||||
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
mlflow.end_run()
|
||||
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config={
|
||||
"save_dir": os.path.join(os.environ["SHARED_STORAGE_PATH"], "mlruns")
|
||||
},
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
)
|
||||
trainer.fit()
|
||||
# __end__
|
||||
|
||||
tempdir.cleanup()
|
||||
@@ -0,0 +1,75 @@
|
||||
# ruff: noqa
|
||||
# fmt: off
|
||||
# isort: off
|
||||
|
||||
# __start__
|
||||
from filelock import FileLock
|
||||
import os
|
||||
|
||||
import torch
|
||||
import wandb
|
||||
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets, transforms
|
||||
from torchvision.models import resnet18
|
||||
|
||||
import ray
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
# Run the following script with the WANDB_API_KEY env var set.
|
||||
assert os.environ.get("WANDB_API_KEY", None), "Please set WANDB_API_KEY env var."
|
||||
|
||||
# This makes sure that all workers have this env var set.
|
||||
ray.init(
|
||||
runtime_env={"env_vars": {"WANDB_API_KEY": os.environ["WANDB_API_KEY"]}}
|
||||
)
|
||||
|
||||
|
||||
def train_func(config):
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
wandb.init()
|
||||
|
||||
# Model, Loss, Optimizer
|
||||
model = resnet18(num_classes=10)
|
||||
model.conv1 = torch.nn.Conv2d(
|
||||
1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False
|
||||
)
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
criterion = torch.nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.Adam(model.module.parameters(), lr=0.001)
|
||||
|
||||
# Data
|
||||
transform = transforms.Compose(
|
||||
[transforms.ToTensor(), transforms.Normalize((0.28604,), (0.32025,))]
|
||||
)
|
||||
with FileLock("./data.lock"):
|
||||
train_data = datasets.FashionMNIST(
|
||||
root="./data", train=True, download=True, transform=transform
|
||||
)
|
||||
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
|
||||
train_loader = ray.train.torch.prepare_data_loader(train_loader)
|
||||
|
||||
# Training
|
||||
for epoch in range(1):
|
||||
if ray.train.get_context().get_world_size() > 1:
|
||||
train_loader.sampler.set_epoch(epoch)
|
||||
|
||||
for images, labels in train_loader:
|
||||
outputs = model(images)
|
||||
loss = criterion(outputs, labels)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
wandb.log({"loss": loss, "epoch": epoch})
|
||||
|
||||
if ray.train.get_context().get_world_rank() == 0:
|
||||
wandb.finish()
|
||||
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
)
|
||||
trainer.fit()
|
||||
@@ -0,0 +1,55 @@
|
||||
# An unique identifier for the head node and workers of this cluster.
|
||||
cluster_name: horovod-cluster
|
||||
|
||||
# The maximum number of workers nodes to launch in addition to the head
|
||||
# node. This takes precedence over min_workers. min_workers default to 0.
|
||||
min_workers: 3
|
||||
max_workers: 3
|
||||
|
||||
# Cloud-provider specific configuration.
|
||||
provider:
|
||||
type: aws
|
||||
region: us-west-2
|
||||
|
||||
# How Ray will authenticate with newly launched nodes.
|
||||
auth:
|
||||
ssh_user: ubuntu
|
||||
|
||||
available_node_types:
|
||||
ray.head.default:
|
||||
min_workers: 0
|
||||
max_workers: 0
|
||||
resources: {}
|
||||
node_config:
|
||||
InstanceType: g3.8xlarge
|
||||
ImageId: latest_dlami
|
||||
InstanceMarketOptions:
|
||||
MarketType: spot
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: /dev/sda1
|
||||
Ebs:
|
||||
VolumeSize: 300
|
||||
|
||||
|
||||
ray.worker.default:
|
||||
min_workers: 3
|
||||
max_workers: 3
|
||||
resources: {}
|
||||
node_config:
|
||||
InstanceType: g3.8xlarge
|
||||
ImageId: latest_dlami
|
||||
InstanceMarketOptions:
|
||||
MarketType: spot
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: /dev/sda1
|
||||
Ebs:
|
||||
VolumeSize: 300
|
||||
|
||||
|
||||
setup_commands:
|
||||
# This replaces the standard anaconda Ray installation
|
||||
- pip install -U https://s3-us-west-2.amazonaws.com/ray-wheels/latest/ray-3.0.0.dev0-cp37-cp37m-manylinux2014_x86_64.whl
|
||||
- pip install ray[tune]
|
||||
|
||||
# Install Horovod
|
||||
- HOROVOD_WITH_GLOO=1 HOROVOD_GPU_OPERATIONS=NCCL HOROVOD_WITHOUT_MPI=1 HOROVOD_WITHOUT_TENSORFLOW=1 HOROVOD_WITHOUT_MXNET=1 HOROVOD_WITH_PYTORCH=1 pip install torch torchvision horovod
|
||||
@@ -0,0 +1,286 @@
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import horovod.torch as hvd
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
import torch.optim as optim
|
||||
import torch.utils.data.distributed
|
||||
from filelock import FileLock
|
||||
from torchvision import datasets, transforms
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.horovod import HorovodTrainer
|
||||
|
||||
|
||||
def metric_average(val, name):
|
||||
tensor = torch.tensor(val)
|
||||
avg_tensor = hvd.allreduce(tensor, name=name)
|
||||
return avg_tensor.item()
|
||||
|
||||
|
||||
class Net(nn.Module):
|
||||
def __init__(self):
|
||||
super(Net, self).__init__()
|
||||
self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
|
||||
self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
|
||||
self.conv2_drop = nn.Dropout2d()
|
||||
self.fc1 = nn.Linear(320, 50)
|
||||
self.fc2 = nn.Linear(50, 10)
|
||||
|
||||
def forward(self, x):
|
||||
x = F.relu(F.max_pool2d(self.conv1(x), 2))
|
||||
x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
|
||||
x = x.view(-1, 320)
|
||||
x = F.relu(self.fc1(x))
|
||||
x = F.dropout(x, training=self.training)
|
||||
x = self.fc2(x)
|
||||
return F.log_softmax(x)
|
||||
|
||||
|
||||
def setup(config):
|
||||
data_dir = config.get("data_dir", None)
|
||||
seed = config.get("seed", 42)
|
||||
batch_size = config.get("batch_size", 64)
|
||||
use_adasum = config.get("use_adasum", False)
|
||||
lr = config.get("lr", 0.01)
|
||||
momentum = config.get("momentum", 0.5)
|
||||
use_cuda = config.get("use_cuda", False)
|
||||
|
||||
# Horovod: initialize library.
|
||||
hvd.init()
|
||||
torch.manual_seed(seed)
|
||||
|
||||
if use_cuda:
|
||||
# Horovod: pin GPU to local rank.
|
||||
torch.cuda.set_device(hvd.local_rank())
|
||||
torch.cuda.manual_seed(seed)
|
||||
|
||||
# Horovod: limit # of CPU threads to be used per worker.
|
||||
torch.set_num_threads(1)
|
||||
|
||||
kwargs = {"num_workers": 1, "pin_memory": True} if use_cuda else {}
|
||||
data_dir = data_dir or "~/data"
|
||||
with FileLock(os.path.expanduser("~/.horovod_lock")):
|
||||
train_dataset = datasets.MNIST(
|
||||
data_dir,
|
||||
train=True,
|
||||
download=True,
|
||||
transform=transforms.Compose(
|
||||
[transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
|
||||
),
|
||||
)
|
||||
# Horovod: use DistributedSampler to partition the training data.
|
||||
train_sampler = torch.utils.data.distributed.DistributedSampler(
|
||||
train_dataset, num_replicas=hvd.size(), rank=hvd.rank()
|
||||
)
|
||||
train_loader = torch.utils.data.DataLoader(
|
||||
train_dataset, batch_size=batch_size, sampler=train_sampler, **kwargs
|
||||
)
|
||||
|
||||
model = Net()
|
||||
|
||||
# By default, Adasum doesn't need scaling up learning rate.
|
||||
lr_scaler = hvd.size() if not use_adasum else 1
|
||||
|
||||
if use_cuda:
|
||||
# Move model to GPU.
|
||||
model.cuda()
|
||||
# If using GPU Adasum allreduce, scale learning rate by local_size.
|
||||
if use_adasum and hvd.nccl_built():
|
||||
lr_scaler = hvd.local_size()
|
||||
|
||||
# Horovod: scale learning rate by lr_scaler.
|
||||
optimizer = optim.SGD(model.parameters(), lr=lr * lr_scaler, momentum=momentum)
|
||||
|
||||
# Horovod: wrap optimizer with DistributedOptimizer.
|
||||
optimizer = hvd.DistributedOptimizer(
|
||||
optimizer,
|
||||
named_parameters=model.named_parameters(),
|
||||
op=hvd.Adasum if use_adasum else hvd.Average,
|
||||
)
|
||||
|
||||
return model, optimizer, train_loader, train_sampler
|
||||
|
||||
|
||||
def train_epoch(
|
||||
model, optimizer, train_sampler, train_loader, epoch, log_interval, use_cuda
|
||||
):
|
||||
loss = None
|
||||
model.train()
|
||||
# Horovod: set epoch to sampler for shuffling.
|
||||
train_sampler.set_epoch(epoch)
|
||||
for batch_idx, (data, target) in enumerate(train_loader):
|
||||
if use_cuda:
|
||||
data, target = data.cuda(), target.cuda()
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.nll_loss(output, target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if batch_idx % log_interval == 0:
|
||||
# Horovod: use train_sampler to determine the number of
|
||||
# examples in this worker's partition.
|
||||
print(
|
||||
"Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}".format(
|
||||
epoch,
|
||||
batch_idx * len(data),
|
||||
len(train_sampler),
|
||||
100.0 * batch_idx / len(train_loader),
|
||||
loss.item(),
|
||||
)
|
||||
)
|
||||
return loss.item() if loss else None
|
||||
|
||||
|
||||
# Horovod function API.
|
||||
|
||||
|
||||
def train_func(config):
|
||||
num_epochs = config.get("num_epochs", 10)
|
||||
log_interval = config.get("log_interval", 10)
|
||||
use_cuda = config.get("use_cuda", False)
|
||||
|
||||
model, optimizer, train_loader, train_sampler = setup(config)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
loss = train_epoch(
|
||||
model, optimizer, train_sampler, train_loader, epoch, log_interval, use_cuda
|
||||
)
|
||||
train.report(dict(loss=loss))
|
||||
|
||||
|
||||
def main(num_workers, use_gpu, kwargs):
|
||||
trainer = HorovodTrainer(
|
||||
train_func,
|
||||
train_loop_config=kwargs,
|
||||
scaling_config=ScalingConfig(use_gpu=use_gpu, num_workers=num_workers),
|
||||
)
|
||||
results = trainer.fit()
|
||||
print(results.metrics)
|
||||
|
||||
|
||||
# Horovod Class API.
|
||||
|
||||
|
||||
class HorovodTrainClass:
|
||||
def __init__(self, config):
|
||||
self.log_interval = config.get("log_interval", 10)
|
||||
self.use_cuda = config.get("use_cuda", False)
|
||||
|
||||
if self.use_cuda:
|
||||
torch.cuda.set_device(hvd.local_rank())
|
||||
|
||||
self.model, self.optimizer, self.train_loader, self.train_sampler = setup(
|
||||
config
|
||||
)
|
||||
|
||||
def train(self, epoch):
|
||||
loss = train_epoch(
|
||||
self.model,
|
||||
self.optimizer,
|
||||
self.train_sampler,
|
||||
self.train_loader,
|
||||
epoch,
|
||||
self.log_interval,
|
||||
self.use_cuda,
|
||||
)
|
||||
return loss
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Training settings
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PyTorch MNIST Example",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=64,
|
||||
metavar="N",
|
||||
help="input batch size for training (default: 64)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-epochs",
|
||||
type=int,
|
||||
default=5,
|
||||
metavar="N",
|
||||
help="number of epochs to train (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lr",
|
||||
type=float,
|
||||
default=0.01,
|
||||
metavar="LR",
|
||||
help="learning rate (default: 0.01)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--momentum",
|
||||
type=float,
|
||||
default=0.5,
|
||||
metavar="M",
|
||||
help="SGD momentum (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="enables CUDA training"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed", type=int, default=42, metavar="S", help="random seed (default: 42)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-interval",
|
||||
type=int,
|
||||
default=10,
|
||||
metavar="N",
|
||||
help="how many batches to wait before logging training status",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-adasum",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="use adasum algorithm to do reduction",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Number of Ray workers to use for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
help="location of the training dataset in the local filesystem ("
|
||||
"will be downloaded if needed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--address",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Address of Ray cluster.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.address:
|
||||
ray.init(args.address)
|
||||
else:
|
||||
ray.init()
|
||||
|
||||
use_cuda = args.use_gpu if args.use_gpu is not None else False
|
||||
|
||||
kwargs = {
|
||||
"data_dir": args.data_dir,
|
||||
"seed": args.seed,
|
||||
"use_cuda": use_cuda,
|
||||
"batch_size": args.batch_size,
|
||||
"use_adasum": args.use_adasum if args.use_adasum else False,
|
||||
"lr": args.lr,
|
||||
"momentum": args.momentum,
|
||||
"num_epochs": args.num_epochs,
|
||||
"log_interval": args.log_interval,
|
||||
}
|
||||
|
||||
main(num_workers=args.num_workers, use_gpu=use_cuda, kwargs=kwargs)
|
||||
@@ -0,0 +1,270 @@
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import horovod.torch as hvd
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
import torch.optim as optim
|
||||
import torch.utils.data.distributed
|
||||
from filelock import FileLock
|
||||
from torchvision import datasets, transforms
|
||||
|
||||
import ray.train.torch
|
||||
from ray import train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.horovod import HorovodTrainer
|
||||
|
||||
|
||||
def metric_average(val, name):
|
||||
tensor = torch.tensor(val)
|
||||
avg_tensor = hvd.allreduce(tensor, name=name)
|
||||
return avg_tensor.item()
|
||||
|
||||
|
||||
class Net(nn.Module):
|
||||
def __init__(self):
|
||||
super(Net, self).__init__()
|
||||
self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
|
||||
self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
|
||||
self.conv2_drop = nn.Dropout2d()
|
||||
self.fc1 = nn.Linear(320, 50)
|
||||
self.fc2 = nn.Linear(50, 10)
|
||||
|
||||
def forward(self, x):
|
||||
x = F.relu(F.max_pool2d(self.conv1(x), 2))
|
||||
x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
|
||||
x = x.view(-1, 320)
|
||||
x = F.relu(self.fc1(x))
|
||||
x = F.dropout(x, training=self.training)
|
||||
x = self.fc2(x)
|
||||
return F.log_softmax(x)
|
||||
|
||||
|
||||
def setup(config):
|
||||
data_dir = config.get("data_dir", None)
|
||||
seed = config.get("seed", 42)
|
||||
batch_size = config.get("batch_size", 64)
|
||||
use_adasum = config.get("use_adasum", False)
|
||||
lr = config.get("lr", 0.01)
|
||||
momentum = config.get("momentum", 0.5)
|
||||
use_cuda = config.get("use_cuda", False)
|
||||
|
||||
# Horovod: initialize library.
|
||||
hvd.init()
|
||||
torch.manual_seed(seed)
|
||||
|
||||
if use_cuda:
|
||||
# Horovod: pin GPU to local rank.
|
||||
torch.cuda.set_device(hvd.local_rank())
|
||||
torch.cuda.manual_seed(seed)
|
||||
|
||||
# Horovod: limit # of CPU threads to be used per worker.
|
||||
torch.set_num_threads(1)
|
||||
|
||||
kwargs = {"pin_memory": True} if use_cuda else {}
|
||||
data_dir = data_dir or "~/data"
|
||||
with FileLock(os.path.expanduser("~/.horovod_lock")):
|
||||
train_dataset = datasets.MNIST(
|
||||
data_dir,
|
||||
train=True,
|
||||
download=True,
|
||||
transform=transforms.Compose(
|
||||
[transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
|
||||
),
|
||||
)
|
||||
# Horovod: use DistributedSampler to partition the training data.
|
||||
train_sampler = torch.utils.data.distributed.DistributedSampler(
|
||||
train_dataset, num_replicas=hvd.size(), rank=hvd.rank()
|
||||
)
|
||||
# Note, don't set `num_workers` in DataLoader (not even 1),
|
||||
# as that will separately start multiple processes (each corresponding to 1 worker)
|
||||
# to load the data. This is known to cause issues with Ray.
|
||||
train_loader = torch.utils.data.DataLoader(
|
||||
train_dataset, batch_size=batch_size, sampler=train_sampler, **kwargs
|
||||
)
|
||||
|
||||
model = Net()
|
||||
|
||||
# By default, Adasum doesn't need scaling up learning rate.
|
||||
lr_scaler = hvd.size() if not use_adasum else 1
|
||||
|
||||
if use_cuda:
|
||||
# Move model to GPU.
|
||||
model.cuda()
|
||||
# If using GPU Adasum allreduce, scale learning rate by local_size.
|
||||
if use_adasum and hvd.nccl_built():
|
||||
lr_scaler = hvd.local_size()
|
||||
|
||||
# Horovod: scale learning rate by lr_scaler.
|
||||
optimizer = optim.SGD(model.parameters(), lr=lr * lr_scaler, momentum=momentum)
|
||||
|
||||
# Horovod: wrap optimizer with DistributedOptimizer.
|
||||
optimizer = hvd.DistributedOptimizer(
|
||||
optimizer,
|
||||
named_parameters=model.named_parameters(),
|
||||
op=hvd.Adasum if use_adasum else hvd.Average,
|
||||
)
|
||||
|
||||
return model, optimizer, train_loader, train_sampler
|
||||
|
||||
|
||||
def train_epoch(
|
||||
model, optimizer, train_sampler, train_loader, epoch, log_interval, use_cuda
|
||||
):
|
||||
loss = None
|
||||
model.train()
|
||||
# Horovod: set epoch to sampler for shuffling.
|
||||
train_sampler.set_epoch(epoch)
|
||||
for batch_idx, (data, target) in enumerate(train_loader):
|
||||
if use_cuda:
|
||||
data, target = data.cuda(), target.cuda()
|
||||
optimizer.zero_grad()
|
||||
output = model(data)
|
||||
loss = F.nll_loss(output, target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
if batch_idx % log_interval == 0:
|
||||
# Horovod: use train_sampler to determine the number of
|
||||
# examples in this worker's partition.
|
||||
print(
|
||||
"Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}".format(
|
||||
epoch,
|
||||
batch_idx * len(data),
|
||||
len(train_sampler),
|
||||
100.0 * batch_idx / len(train_loader),
|
||||
loss.item(),
|
||||
)
|
||||
)
|
||||
return loss.item() if loss else None
|
||||
|
||||
|
||||
def train_func(config):
|
||||
num_epochs = config.get("num_epochs", 10)
|
||||
log_interval = config.get("log_interval", 10)
|
||||
use_cuda = config.get("use_cuda", False)
|
||||
|
||||
model, optimizer, train_loader, train_sampler = setup(config)
|
||||
|
||||
results = []
|
||||
for epoch in range(num_epochs):
|
||||
loss = train_epoch(
|
||||
model, optimizer, train_sampler, train_loader, epoch, log_interval, use_cuda
|
||||
)
|
||||
results.append(loss)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
torch.save(model.state_dict(), os.path.join(tmpdir, "model.pt"))
|
||||
train.report({"loss": loss}, checkpoint=Checkpoint.from_directory(tmpdir))
|
||||
|
||||
# Only used for testing.
|
||||
return results
|
||||
|
||||
|
||||
def main(num_workers, use_gpu, kwargs):
|
||||
trainer = HorovodTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config={
|
||||
"num_epochs": kwargs["num_epochs"],
|
||||
"log_interval": kwargs["log_interval"],
|
||||
"use_cuda": kwargs["use_cuda"],
|
||||
},
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
)
|
||||
result = trainer.fit()
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Training settings
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PyTorch MNIST Example",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=64,
|
||||
metavar="N",
|
||||
help="input batch size for training (default: 64)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-epochs",
|
||||
type=int,
|
||||
default=5,
|
||||
metavar="N",
|
||||
help="number of epochs to train (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lr",
|
||||
type=float,
|
||||
default=0.01,
|
||||
metavar="LR",
|
||||
help="learning rate (default: 0.01)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--momentum",
|
||||
type=float,
|
||||
default=0.5,
|
||||
metavar="M",
|
||||
help="SGD momentum (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="enables CUDA training"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed", type=int, default=42, metavar="S", help="random seed (default: 42)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-interval",
|
||||
type=int,
|
||||
default=10,
|
||||
metavar="N",
|
||||
help="how many batches to wait before logging training status",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-adasum",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="use adasum algorithm to do reduction",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Number of Ray workers to use for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
help="location of the training dataset in the local filesystem ("
|
||||
"will be downloaded if needed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--address",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Address of Ray cluster.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.address:
|
||||
ray.init(args.address)
|
||||
else:
|
||||
ray.init()
|
||||
|
||||
use_cuda = args.use_gpu if args.use_gpu is not None else False
|
||||
|
||||
kwargs = {
|
||||
"data_dir": args.data_dir,
|
||||
"seed": args.seed,
|
||||
"use_cuda": use_cuda,
|
||||
"batch_size": args.batch_size,
|
||||
"use_adasum": args.use_adasum if args.use_adasum else False,
|
||||
"lr": args.lr,
|
||||
"momentum": args.momentum,
|
||||
"num_epochs": args.num_epochs,
|
||||
"log_interval": args.log_interval,
|
||||
}
|
||||
|
||||
main(num_workers=args.num_workers, use_gpu=use_cuda, kwargs=kwargs)
|
||||
@@ -0,0 +1,139 @@
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
import ray
|
||||
import ray.train.torch
|
||||
from ray import train, tune
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.horovod import HorovodTrainer
|
||||
from ray.tune.tune_config import TuneConfig
|
||||
from ray.tune.tuner import Tuner
|
||||
|
||||
|
||||
def sq(x):
|
||||
m2 = 1.0
|
||||
m1 = -20.0
|
||||
m0 = 50.0
|
||||
return m2 * x * x + m1 * x + m0
|
||||
|
||||
|
||||
def qu(x):
|
||||
m3 = 10.0
|
||||
m2 = 5.0
|
||||
m1 = -20.0
|
||||
m0 = -5.0
|
||||
return m3 * x * x * x + m2 * x * x + m1 * x + m0
|
||||
|
||||
|
||||
class Net(torch.nn.Module):
|
||||
def __init__(self, mode="sq"):
|
||||
super(Net, self).__init__()
|
||||
|
||||
if mode == "square":
|
||||
self.mode = 0
|
||||
self.param = torch.nn.Parameter(torch.FloatTensor([1.0, -1.0]))
|
||||
else:
|
||||
self.mode = 1
|
||||
self.param = torch.nn.Parameter(torch.FloatTensor([1.0, -1.0, 1.0]))
|
||||
|
||||
def forward(self, x):
|
||||
if ~self.mode:
|
||||
return x * x + self.param[0] * x + self.param[1]
|
||||
else:
|
||||
return_val = 10 * x * x * x
|
||||
return_val += self.param[0] * x * x
|
||||
return_val += self.param[1] * x + self.param[2]
|
||||
return return_val
|
||||
|
||||
|
||||
def train_loop_per_worker(config):
|
||||
import horovod.torch as hvd
|
||||
import torch
|
||||
|
||||
hvd.init()
|
||||
device = ray.train.torch.get_device()
|
||||
mode = config["mode"]
|
||||
net = Net(mode).to(device)
|
||||
optimizer = torch.optim.SGD(
|
||||
net.parameters(),
|
||||
lr=config["lr"],
|
||||
)
|
||||
optimizer = hvd.DistributedOptimizer(optimizer)
|
||||
|
||||
num_steps = 5
|
||||
print(hvd.size())
|
||||
np.random.seed(1 + hvd.rank())
|
||||
torch.manual_seed(1234)
|
||||
# To ensure consistent initialization across workers,
|
||||
hvd.broadcast_parameters(net.state_dict(), root_rank=0)
|
||||
hvd.broadcast_optimizer_state(optimizer, root_rank=0)
|
||||
|
||||
start = time.time()
|
||||
x_max = config["x_max"]
|
||||
for step in range(1, num_steps + 1):
|
||||
features = torch.Tensor(np.random.rand(1) * 2 * x_max - x_max).to(device)
|
||||
if mode == "square":
|
||||
labels = sq(features)
|
||||
else:
|
||||
labels = qu(features)
|
||||
optimizer.zero_grad()
|
||||
outputs = net(features)
|
||||
loss = torch.nn.MSELoss()(outputs, labels)
|
||||
loss.backward()
|
||||
|
||||
optimizer.step()
|
||||
time.sleep(0.1)
|
||||
train.report(dict(loss=loss.item()))
|
||||
total = time.time() - start
|
||||
print(f"Took {total:0.3f} s. Avg: {total / num_steps:0.3f} s.")
|
||||
|
||||
|
||||
def tune_horovod(num_workers, num_samples, use_gpu, mode="square", x_max=1.0):
|
||||
horovod_trainer = HorovodTrainer(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
train_loop_config={"mode": mode, "x_max": x_max},
|
||||
)
|
||||
|
||||
tuner = Tuner(
|
||||
horovod_trainer,
|
||||
param_space={"train_loop_config": {"lr": tune.uniform(0.1, 1)}},
|
||||
tune_config=TuneConfig(mode="min", metric="loss", num_samples=num_samples),
|
||||
_tuner_kwargs={"fail_fast": True},
|
||||
)
|
||||
|
||||
result_grid = tuner.fit()
|
||||
|
||||
print("Best hyperparameters found were: ", result_grid.get_best_result().config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--mode", type=str, default="square", choices=["square", "cubic"]
|
||||
)
|
||||
parser.add_argument(
|
||||
"--learning_rate", type=float, default=0.1, dest="learning_rate"
|
||||
)
|
||||
parser.add_argument("--x_max", type=float, default=1.0, dest="x_max")
|
||||
parser.add_argument("--gpu", action="store_true")
|
||||
parser.add_argument(
|
||||
"--smoke-test", action="store_true", help=("Finish quickly for testing.")
|
||||
)
|
||||
parser.add_argument("--num-workers", type=int, default=2)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.smoke_test:
|
||||
ray.init(num_cpus=3)
|
||||
|
||||
tune_horovod(
|
||||
num_workers=args.num_workers,
|
||||
num_samples=2 if args.smoke_test else 10,
|
||||
use_gpu=args.gpu,
|
||||
mode=args.mode,
|
||||
x_max=args.x_max,
|
||||
)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# The PyTorch data transfer benchmark script.
|
||||
import argparse
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import ray.train as train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
class Net(nn.Module):
|
||||
def __init__(self, in_d, hidden):
|
||||
# output dim = 1
|
||||
super(Net, self).__init__()
|
||||
dims = [in_d] + hidden + [1]
|
||||
self.layers = nn.ModuleList(
|
||||
[nn.Linear(dims[i - 1], dims[i]) for i in range(len(dims))]
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
for layer in self.layers:
|
||||
x = layer(x)
|
||||
return x
|
||||
|
||||
|
||||
class BenchmarkDataset(torch.utils.data.Dataset):
|
||||
"""Create a naive dataset for the benchmark"""
|
||||
|
||||
def __init__(self, dim, size=1000):
|
||||
self.x = torch.from_numpy(np.random.normal(size=(size, dim))).float()
|
||||
self.y = torch.from_numpy(np.random.normal(size=(size, 1))).float()
|
||||
self.size = size
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.x[index, None], self.y[index, None]
|
||||
|
||||
def __len__(self):
|
||||
return self.size
|
||||
|
||||
|
||||
def train_epoch(epoch, dataloader, model, loss_fn, optimizer):
|
||||
if train.get_context().get_world_size() > 1:
|
||||
dataloader.sampler.set_epoch(epoch)
|
||||
|
||||
for X, y in dataloader:
|
||||
# Compute prediction error
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
# Backpropagation
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
|
||||
def train_func(config):
|
||||
data_size = config.get("data_size", 4096 * 50)
|
||||
batch_size = config.get("batch_size", 4096)
|
||||
hidden_size = config.get("hidden_size", 1)
|
||||
use_auto_transfer = config.get("use_auto_transfer", False)
|
||||
lr = config.get("lr", 1e-2)
|
||||
epochs = config.get("epochs", 10)
|
||||
|
||||
train_dataset = BenchmarkDataset(4096, size=data_size)
|
||||
train_loader = torch.utils.data.DataLoader(
|
||||
train_dataset, batch_size=batch_size, shuffle=True
|
||||
)
|
||||
|
||||
train_loader = train.torch.prepare_data_loader(
|
||||
data_loader=train_loader, move_to_device=True, auto_transfer=use_auto_transfer
|
||||
)
|
||||
|
||||
model = Net(in_d=4096, hidden=[4096] * hidden_size)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
loss_fn = nn.MSELoss()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
|
||||
|
||||
start = torch.cuda.Event(enable_timing=True)
|
||||
end = torch.cuda.Event(enable_timing=True)
|
||||
|
||||
choice = "with" if use_auto_transfer else "without"
|
||||
print(f"Starting the torch data prefetch benchmark {choice} auto pipeline...")
|
||||
|
||||
torch.cuda.synchronize()
|
||||
start.record()
|
||||
for epoch in range(epochs):
|
||||
train_epoch(epoch, train_loader, model, loss_fn, optimizer)
|
||||
end.record()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
print(
|
||||
f"Finished the torch data prefetch benchmark {choice} "
|
||||
f"auto pipeline: {start.elapsed_time(end)} ms."
|
||||
)
|
||||
|
||||
return "Experiment done."
|
||||
|
||||
|
||||
def train_linear(num_workers=1, num_hidden_layers=1, use_auto_transfer=True, epochs=3):
|
||||
config = {
|
||||
"lr": 1e-2,
|
||||
"hidden_size": num_hidden_layers,
|
||||
"batch_size": 4096,
|
||||
"epochs": epochs,
|
||||
"use_auto_transfer": use_auto_transfer,
|
||||
}
|
||||
trainer = TorchTrainer(
|
||||
train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(use_gpu=True, num_workers=num_workers),
|
||||
)
|
||||
results = trainer.fit()
|
||||
|
||||
print(results.metrics)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs", type=int, default=1, help="Number of epochs to train for."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num_hidden_layers",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of epochs to train for.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
import ray
|
||||
|
||||
ray.init(address=args.address)
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
warnings.warn("GPU is not available. Skip the test using auto pipeline.")
|
||||
else:
|
||||
train_linear(
|
||||
num_workers=1,
|
||||
num_hidden_layers=args.num_hidden_layers,
|
||||
use_auto_transfer=True,
|
||||
epochs=args.epochs,
|
||||
)
|
||||
|
||||
torch.cuda.empty_cache()
|
||||
train_linear(
|
||||
num_workers=1,
|
||||
num_hidden_layers=args.num_hidden_layers,
|
||||
use_auto_transfer=False,
|
||||
epochs=args.epochs,
|
||||
)
|
||||
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
from typing import Dict
|
||||
|
||||
import torch
|
||||
from filelock import FileLock
|
||||
from torch import nn
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets, transforms
|
||||
from torchvision.transforms import Normalize, ToTensor
|
||||
from tqdm import tqdm
|
||||
|
||||
import ray.train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def get_dataloaders(batch_size):
|
||||
# Transform to normalize the input images
|
||||
transform = transforms.Compose([ToTensor(), Normalize((0.28604,), (0.32025,))])
|
||||
|
||||
with FileLock(os.path.expanduser("~/data.lock")):
|
||||
# Download training data from open datasets
|
||||
training_data = datasets.FashionMNIST(
|
||||
root="~/data",
|
||||
train=True,
|
||||
download=True,
|
||||
transform=transform,
|
||||
)
|
||||
|
||||
# Download test data from open datasets
|
||||
test_data = datasets.FashionMNIST(
|
||||
root="~/data",
|
||||
train=False,
|
||||
download=True,
|
||||
transform=transform,
|
||||
)
|
||||
|
||||
# Create data loaders
|
||||
train_dataloader = DataLoader(training_data, batch_size=batch_size, shuffle=True)
|
||||
test_dataloader = DataLoader(test_data, batch_size=batch_size)
|
||||
|
||||
return train_dataloader, test_dataloader
|
||||
|
||||
|
||||
# Model Definition
|
||||
class NeuralNetwork(nn.Module):
|
||||
def __init__(self):
|
||||
super(NeuralNetwork, self).__init__()
|
||||
self.flatten = nn.Flatten()
|
||||
self.linear_relu_stack = nn.Sequential(
|
||||
nn.Linear(28 * 28, 512),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(0.25),
|
||||
nn.Linear(512, 512),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(0.25),
|
||||
nn.Linear(512, 10),
|
||||
nn.ReLU(),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.flatten(x)
|
||||
logits = self.linear_relu_stack(x)
|
||||
return logits
|
||||
|
||||
|
||||
def train_func_per_worker(config: Dict):
|
||||
ray.train.torch.enable_reproducibility()
|
||||
|
||||
lr = config["lr"]
|
||||
epochs = config["epochs"]
|
||||
batch_size = config["batch_size_per_worker"]
|
||||
|
||||
# Get dataloaders inside the worker training function
|
||||
train_dataloader, test_dataloader = get_dataloaders(batch_size=batch_size)
|
||||
|
||||
# [1] Prepare Dataloader for distributed training
|
||||
# Shard the datasets among workers and move batches to the correct device
|
||||
# =======================================================================
|
||||
train_dataloader = ray.train.torch.prepare_data_loader(train_dataloader)
|
||||
test_dataloader = ray.train.torch.prepare_data_loader(test_dataloader)
|
||||
|
||||
model = NeuralNetwork()
|
||||
|
||||
# [2] Prepare and wrap your model with DistributedDataParallel
|
||||
# Move the model to the correct GPU/CPU device
|
||||
# ============================================================
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
|
||||
loss_fn = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9)
|
||||
|
||||
# Model training loop
|
||||
for epoch in range(epochs):
|
||||
if ray.train.get_context().get_world_size() > 1:
|
||||
# Required for the distributed sampler to shuffle properly across epochs.
|
||||
train_dataloader.sampler.set_epoch(epoch)
|
||||
|
||||
model.train()
|
||||
for X, y in tqdm(train_dataloader, desc=f"Train Epoch {epoch}"):
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
model.eval()
|
||||
test_loss, num_correct, num_total = 0, 0, 0
|
||||
with torch.no_grad():
|
||||
for X, y in tqdm(test_dataloader, desc=f"Test Epoch {epoch}"):
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
test_loss += loss.item()
|
||||
num_total += y.shape[0]
|
||||
num_correct += (pred.argmax(1) == y).sum().item()
|
||||
|
||||
test_loss /= len(test_dataloader)
|
||||
accuracy = num_correct / num_total
|
||||
|
||||
# [3] Report metrics to Ray Train
|
||||
# ===============================
|
||||
ray.train.report(metrics={"loss": test_loss, "accuracy": accuracy})
|
||||
|
||||
|
||||
def train_fashion_mnist(num_workers=2, use_gpu=False):
|
||||
global_batch_size = 32
|
||||
|
||||
train_config = {
|
||||
"lr": 1e-3,
|
||||
"epochs": 10,
|
||||
"batch_size_per_worker": global_batch_size // num_workers,
|
||||
}
|
||||
|
||||
# Configure computation resources
|
||||
scaling_config = ScalingConfig(num_workers=num_workers, use_gpu=use_gpu)
|
||||
|
||||
# Initialize a Ray TorchTrainer
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func_per_worker,
|
||||
train_loop_config=train_config,
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
# [4] Start distributed training
|
||||
# Run `train_func_per_worker` on all workers
|
||||
# =============================================
|
||||
result = trainer.fit()
|
||||
print(f"Training result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
train_fashion_mnist(num_workers=4, use_gpu=True)
|
||||
@@ -0,0 +1,147 @@
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import ray.train as train
|
||||
from ray.train import Checkpoint, RunConfig, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
class LinearDataset(torch.utils.data.Dataset):
|
||||
"""y = a * x + b"""
|
||||
|
||||
def __init__(self, a, b, size=1000):
|
||||
x = np.arange(0, 10, 10 / size, dtype=np.float32)
|
||||
self.x = torch.from_numpy(x)
|
||||
self.y = torch.from_numpy(a * x + b)
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.x[index, None], self.y[index, None]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.x)
|
||||
|
||||
|
||||
def train_epoch(epoch, dataloader, model, loss_fn, optimizer):
|
||||
if train.get_context().get_world_size() > 1:
|
||||
dataloader.sampler.set_epoch(epoch)
|
||||
|
||||
for X, y in dataloader:
|
||||
# Compute prediction error
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
# Backpropagation
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
|
||||
def validate_epoch(dataloader, model, loss_fn):
|
||||
num_batches = len(dataloader)
|
||||
model.eval()
|
||||
loss = 0
|
||||
with torch.no_grad():
|
||||
for X, y in dataloader:
|
||||
pred = model(X)
|
||||
loss += loss_fn(pred, y).item()
|
||||
loss /= num_batches
|
||||
import copy
|
||||
|
||||
model_copy = copy.deepcopy(model)
|
||||
return model_copy.cpu().state_dict(), loss
|
||||
|
||||
|
||||
def train_func(config):
|
||||
data_size = config.get("data_size", 1000)
|
||||
val_size = config.get("val_size", 400)
|
||||
batch_size = config.get("batch_size", 32)
|
||||
hidden_size = config.get("hidden_size", 1)
|
||||
lr = config.get("lr", 1e-2)
|
||||
epochs = config.get("epochs", 3)
|
||||
|
||||
train_dataset = LinearDataset(2, 5, size=data_size)
|
||||
val_dataset = LinearDataset(2, 5, size=val_size)
|
||||
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size)
|
||||
validation_loader = torch.utils.data.DataLoader(val_dataset, batch_size=batch_size)
|
||||
|
||||
train_loader = train.torch.prepare_data_loader(train_loader)
|
||||
validation_loader = train.torch.prepare_data_loader(validation_loader)
|
||||
|
||||
model = nn.Linear(1, hidden_size)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
loss_fn = nn.MSELoss()
|
||||
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
|
||||
|
||||
results = []
|
||||
for epoch in range(epochs):
|
||||
train_epoch(epoch, train_loader, model, loss_fn, optimizer)
|
||||
state_dict, loss = validate_epoch(validation_loader, model, loss_fn)
|
||||
result = dict(loss=loss)
|
||||
results.append(result)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
torch.save(state_dict, os.path.join(tmpdir, "model.pt"))
|
||||
train.report(result, checkpoint=Checkpoint.from_directory(tmpdir))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def train_linear(num_workers=2, use_gpu=False, epochs=3, storage_path=None):
|
||||
config = {"lr": 1e-2, "hidden_size": 1, "batch_size": 4, "epochs": epochs}
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
run_config=RunConfig(storage_path=storage_path),
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
print(result.metrics)
|
||||
return result.metrics
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", help="Whether to use GPU for training."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs", type=int, default=3, help="Number of epochs to train for."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Finish quickly for testing.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
import ray
|
||||
|
||||
if args.smoke_test:
|
||||
# 2 workers + 1 for trainer.
|
||||
ray.init(num_cpus=3)
|
||||
train_linear()
|
||||
else:
|
||||
ray.init(address=args.address)
|
||||
train_linear(
|
||||
num_workers=args.num_workers, use_gpu=args.use_gpu, epochs=args.epochs
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
# ruff: noqa
|
||||
# fmt: off
|
||||
# isort: skip_file
|
||||
|
||||
# __torch_setup_begin__
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader
|
||||
from torchvision import datasets
|
||||
from torchvision.transforms import ToTensor
|
||||
|
||||
def get_dataset():
|
||||
return datasets.FashionMNIST(
|
||||
root="/tmp/data",
|
||||
train=True,
|
||||
download=True,
|
||||
transform=ToTensor(),
|
||||
)
|
||||
|
||||
class NeuralNetwork(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.flatten = nn.Flatten()
|
||||
self.linear_relu_stack = nn.Sequential(
|
||||
nn.Linear(28 * 28, 512),
|
||||
nn.ReLU(),
|
||||
nn.Linear(512, 512),
|
||||
nn.ReLU(),
|
||||
nn.Linear(512, 10),
|
||||
)
|
||||
|
||||
def forward(self, inputs):
|
||||
inputs = self.flatten(inputs)
|
||||
logits = self.linear_relu_stack(inputs)
|
||||
return logits
|
||||
# __torch_setup_end__
|
||||
|
||||
# __torch_single_begin__
|
||||
def train_func():
|
||||
num_epochs = 3
|
||||
batch_size = 64
|
||||
|
||||
dataset = get_dataset()
|
||||
dataloader = DataLoader(dataset, batch_size=batch_size)
|
||||
|
||||
model = NeuralNetwork()
|
||||
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
for inputs, labels in dataloader:
|
||||
optimizer.zero_grad()
|
||||
pred = model(inputs)
|
||||
loss = criterion(pred, labels)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
print(f"epoch: {epoch}, loss: {loss.item()}")
|
||||
# __torch_single_end__
|
||||
|
||||
# __torch_distributed_begin__
|
||||
import ray.train.torch
|
||||
|
||||
def train_func_distributed():
|
||||
num_epochs = 3
|
||||
batch_size = 64
|
||||
|
||||
dataset = get_dataset()
|
||||
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
dataloader = ray.train.torch.prepare_data_loader(dataloader)
|
||||
|
||||
model = NeuralNetwork()
|
||||
model = ray.train.torch.prepare_model(model)
|
||||
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
if ray.train.get_context().get_world_size() > 1:
|
||||
dataloader.sampler.set_epoch(epoch)
|
||||
|
||||
for inputs, labels in dataloader:
|
||||
optimizer.zero_grad()
|
||||
pred = model(inputs)
|
||||
loss = criterion(pred, labels)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
print(f"epoch: {epoch}, loss: {loss.item()}")
|
||||
# __torch_distributed_end__
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# __torch_single_run_begin__
|
||||
train_func()
|
||||
# __torch_single_run_end__
|
||||
|
||||
# __torch_trainer_begin__
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.train import ScalingConfig
|
||||
|
||||
# For GPU Training, set `use_gpu` to True.
|
||||
use_gpu = False
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_func_distributed,
|
||||
scaling_config=ScalingConfig(num_workers=4, use_gpu=use_gpu)
|
||||
)
|
||||
|
||||
results = trainer.fit()
|
||||
# __torch_trainer_end__
|
||||
@@ -0,0 +1,159 @@
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Tuple
|
||||
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import ray
|
||||
import ray.train as train
|
||||
from ray.data import Dataset
|
||||
from ray.train import Checkpoint, DataConfig, ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
def get_datasets(split: float = 0.7) -> Tuple[Dataset]:
|
||||
dataset = ray.data.read_csv("s3://anonymous@air-example-data/regression.csv")
|
||||
|
||||
def combine_x(batch):
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"x": batch[[f"x{i:03d}" for i in range(100)]].values.tolist(),
|
||||
"y": batch["y"],
|
||||
}
|
||||
)
|
||||
|
||||
dataset = dataset.map_batches(combine_x, batch_format="pandas")
|
||||
train_dataset, validation_dataset = dataset.repartition(
|
||||
num_blocks=4
|
||||
).train_test_split(split, shuffle=True)
|
||||
return train_dataset, validation_dataset
|
||||
|
||||
|
||||
def train_epoch(iterable_dataset, model, loss_fn, optimizer, device):
|
||||
model.train()
|
||||
for X, y in iterable_dataset:
|
||||
X = X.to(device)
|
||||
y = y.to(device)
|
||||
|
||||
# Compute prediction error
|
||||
pred = model(X)
|
||||
loss = loss_fn(pred, y)
|
||||
|
||||
# Backpropagation
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
|
||||
def validate_epoch(iterable_dataset, model, loss_fn, device):
|
||||
num_batches = 0
|
||||
model.eval()
|
||||
loss = 0
|
||||
with torch.no_grad():
|
||||
for X, y in iterable_dataset:
|
||||
X = X.to(device)
|
||||
y = y.to(device)
|
||||
num_batches += 1
|
||||
pred = model(X)
|
||||
loss += loss_fn(pred, y).item()
|
||||
loss /= num_batches
|
||||
result = {"loss": loss}
|
||||
return result
|
||||
|
||||
|
||||
def train_func(config):
|
||||
batch_size = config.get("batch_size", 32)
|
||||
hidden_size = config.get("hidden_size", 10)
|
||||
lr = config.get("lr", 1e-2)
|
||||
epochs = config.get("epochs", 3)
|
||||
|
||||
train_dataset_shard = train.get_dataset_shard("train")
|
||||
validation_dataset = train.get_dataset_shard("validation")
|
||||
|
||||
model = nn.Sequential(
|
||||
nn.Linear(100, hidden_size), nn.ReLU(), nn.Linear(hidden_size, 1)
|
||||
)
|
||||
model = train.torch.prepare_model(model)
|
||||
|
||||
loss_fn = nn.L1Loss()
|
||||
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
|
||||
|
||||
results = []
|
||||
|
||||
def create_torch_iterator(shard):
|
||||
iterator = shard.iter_torch_batches(batch_size=batch_size)
|
||||
for batch in iterator:
|
||||
yield batch["x"].float(), batch["y"].float()
|
||||
|
||||
for _ in range(epochs):
|
||||
train_torch_dataset = create_torch_iterator(train_dataset_shard)
|
||||
validation_torch_dataset = create_torch_iterator(validation_dataset)
|
||||
|
||||
device = train.torch.get_device()
|
||||
|
||||
train_epoch(train_torch_dataset, model, loss_fn, optimizer, device)
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
result = validate_epoch(validation_torch_dataset, model, loss_fn, device)
|
||||
else:
|
||||
result = {}
|
||||
results.append(result)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
torch.save(model.module.state_dict(), os.path.join(tmpdir, "model.pt"))
|
||||
train.report(result, checkpoint=Checkpoint.from_directory(tmpdir))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def train_regression(num_workers=2, use_gpu=False):
|
||||
train_dataset, val_dataset = get_datasets()
|
||||
config = {"lr": 1e-2, "hidden_size": 20, "batch_size": 4, "epochs": 3}
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
datasets={"train": train_dataset, "validation": val_dataset},
|
||||
dataset_config=DataConfig(datasets_to_split=["train"]),
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Finish quickly for testing.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="Use GPU for training."
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.smoke_test:
|
||||
# 2 workers, 1 for trainer, 1 for datasets
|
||||
ray.init(num_cpus=4)
|
||||
result = train_regression()
|
||||
else:
|
||||
ray.init(address=args.address)
|
||||
result = train_regression(num_workers=args.num_workers, use_gpu=args.use_gpu)
|
||||
print(result)
|
||||
@@ -0,0 +1,228 @@
|
||||
# Adapted from https://github.com/pyg-team/pytorch_geometric/blob/2.1.0
|
||||
# /examples/multi_gpu/distributed_sampling.py
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from filelock import FileLock
|
||||
from torch_geometric.datasets import FakeDataset, Reddit
|
||||
from torch_geometric.loader import NeighborSampler
|
||||
from torch_geometric.nn import SAGEConv
|
||||
from torch_geometric.transforms import RandomNodeSplit
|
||||
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
class SAGE(torch.nn.Module):
|
||||
def __init__(self, in_channels, hidden_channels, out_channels, num_layers=2):
|
||||
super().__init__()
|
||||
self.num_layers = num_layers
|
||||
|
||||
self.convs = torch.nn.ModuleList()
|
||||
self.convs.append(SAGEConv(in_channels, hidden_channels))
|
||||
for _ in range(self.num_layers - 2):
|
||||
self.convs.append(SAGEConv(hidden_channels, hidden_channels))
|
||||
self.convs.append(SAGEConv(hidden_channels, out_channels))
|
||||
|
||||
def forward(self, x, adjs):
|
||||
for i, (edge_index, _, size) in enumerate(adjs):
|
||||
x_target = x[: size[1]] # Target nodes are always placed first.
|
||||
x = self.convs[i]((x, x_target), edge_index)
|
||||
if i != self.num_layers - 1:
|
||||
x = F.relu(x)
|
||||
x = F.dropout(x, p=0.5, training=self.training)
|
||||
return x.log_softmax(dim=-1)
|
||||
|
||||
@torch.no_grad()
|
||||
def test(self, x_all, subgraph_loader):
|
||||
for i in range(self.num_layers):
|
||||
xs = []
|
||||
for batch_size, n_id, adj in subgraph_loader:
|
||||
edge_index, _, size = adj
|
||||
x = x_all[n_id.to(x_all.device)].to(train.torch.get_device())
|
||||
x_target = x[: size[1]]
|
||||
x = self.convs[i]((x, x_target), edge_index)
|
||||
if i != self.num_layers - 1:
|
||||
x = F.relu(x)
|
||||
xs.append(x.cpu())
|
||||
|
||||
x_all = torch.cat(xs, dim=0)
|
||||
|
||||
return x_all
|
||||
|
||||
|
||||
def train_loop_per_worker(train_loop_config):
|
||||
dataset = train_loop_config["dataset_fn"]()
|
||||
batch_size = train_loop_config["batch_size"]
|
||||
num_epochs = train_loop_config["num_epochs"]
|
||||
|
||||
data = dataset[0]
|
||||
train_idx = data.train_mask.nonzero(as_tuple=False).view(-1)
|
||||
train_idx = train_idx.split(
|
||||
train_idx.size(0) // train.get_context().get_world_size()
|
||||
)[train.get_context().get_world_rank()]
|
||||
|
||||
train_loader = NeighborSampler(
|
||||
data.edge_index,
|
||||
node_idx=train_idx,
|
||||
sizes=[25, 10],
|
||||
batch_size=batch_size,
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
# Disable distributed sampler since the train_loader has already been split above.
|
||||
train_loader = train.torch.prepare_data_loader(train_loader, add_dist_sampler=False)
|
||||
|
||||
# Do validation on rank 0 worker only.
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
subgraph_loader = NeighborSampler(
|
||||
data.edge_index, node_idx=None, sizes=[-1], batch_size=2048, shuffle=False
|
||||
)
|
||||
subgraph_loader = train.torch.prepare_data_loader(
|
||||
subgraph_loader, add_dist_sampler=False
|
||||
)
|
||||
|
||||
model = SAGE(dataset.num_features, 256, dataset.num_classes)
|
||||
model = train.torch.prepare_model(model)
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
|
||||
|
||||
x, y = data.x.to(train.torch.get_device()), data.y.to(train.torch.get_device())
|
||||
|
||||
for epoch in range(num_epochs):
|
||||
model.train()
|
||||
|
||||
# ``batch_size`` is the number of samples in the current batch.
|
||||
# ``n_id`` are the ids of all the nodes used in the computation. This is
|
||||
# needed to pull in the necessary features just for the current batch that is
|
||||
# being trained on.
|
||||
# ``adjs`` is a list of 3 element tuple consisting of ``(edge_index, e_id,
|
||||
# size)`` for each sample in the batch, where ``edge_index``represent the
|
||||
# edges of the sampled subgraph, ``e_id`` are the ids of the edges in the
|
||||
# sample, and ``size`` holds the shape of the subgraph.
|
||||
# See ``torch_geometric.loader.neighbor_sampler.NeighborSampler`` for more info.
|
||||
for batch_size, n_id, adjs in train_loader:
|
||||
optimizer.zero_grad()
|
||||
out = model(x[n_id], adjs)
|
||||
loss = F.nll_loss(out, y[n_id[:batch_size]])
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
print(f"Epoch: {epoch:03d}, Loss: {loss:.4f}")
|
||||
|
||||
train_accuracy = validation_accuracy = test_accuracy = None
|
||||
|
||||
# Do validation on rank 0 worker only.
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
out = model.module.test(x, subgraph_loader)
|
||||
res = out.argmax(dim=-1) == data.y
|
||||
train_accuracy = int(res[data.train_mask].sum()) / int(
|
||||
data.train_mask.sum()
|
||||
)
|
||||
validation_accuracy = int(res[data.val_mask].sum()) / int(
|
||||
data.val_mask.sum()
|
||||
)
|
||||
test_accuracy = int(res[data.test_mask].sum()) / int(data.test_mask.sum())
|
||||
|
||||
train.report(
|
||||
dict(
|
||||
train_accuracy=train_accuracy,
|
||||
validation_accuracy=validation_accuracy,
|
||||
test_accuracy=test_accuracy,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def gen_fake_dataset():
|
||||
"""Returns a function to be called on each worker that returns a Fake Dataset."""
|
||||
|
||||
# For fake dataset, since the dataset is randomized, we create it once on the
|
||||
# driver, and then send the same dataset to all the training workers.
|
||||
# Use 10% of nodes for validation and 10% for testing.
|
||||
fake_dataset = FakeDataset(transform=RandomNodeSplit(num_val=0.1, num_test=0.1))
|
||||
|
||||
def gen_dataset():
|
||||
return fake_dataset
|
||||
|
||||
return gen_dataset
|
||||
|
||||
|
||||
def gen_reddit_dataset():
|
||||
"""Returns a function to be called on each worker that returns Reddit Dataset."""
|
||||
|
||||
# For Reddit dataset, we have to download the data on each node, so we create the
|
||||
# dataset on each training worker.
|
||||
with FileLock(os.path.expanduser("~/.reddit_dataset_lock")):
|
||||
dataset = Reddit("./data/Reddit")
|
||||
return dataset
|
||||
|
||||
|
||||
def train_gnn(
|
||||
num_workers=2, use_gpu=False, epochs=3, global_batch_size=32, dataset="reddit"
|
||||
):
|
||||
per_worker_batch_size = global_batch_size // num_workers
|
||||
|
||||
trainer = TorchTrainer(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config={
|
||||
"num_epochs": epochs,
|
||||
"batch_size": per_worker_batch_size,
|
||||
"dataset_fn": gen_reddit_dataset
|
||||
if dataset == "reddit"
|
||||
else gen_fake_dataset(),
|
||||
},
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
)
|
||||
result = trainer.fit()
|
||||
print(result.metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", help="Whether to use GPU for training."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs", type=int, default=3, help="Number of epochs to train for."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--global-batch-size",
|
||||
"-b",
|
||||
type=int,
|
||||
default=32,
|
||||
help="Global batch size to use for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset",
|
||||
"-d",
|
||||
type=str,
|
||||
choices=["reddit", "fake"],
|
||||
default="reddit",
|
||||
help="The dataset to use. Either 'reddit' or 'fake' Defaults to 'reddit'.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
train_gnn(
|
||||
num_workers=args.num_workers,
|
||||
use_gpu=args.use_gpu,
|
||||
epochs=args.epochs,
|
||||
global_batch_size=args.global_batch_size,
|
||||
dataset=args.dataset,
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
# This example showcases how to use Tensorflow with Ray Train.
|
||||
# Original code:
|
||||
# https://www.tensorflow.org/tutorials/distribute/multi_worker_with_keras
|
||||
import os
|
||||
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import argparse
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import tensorflow as tf
|
||||
import tensorflow_datasets as tfds
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.air.integrations.keras import ReportCheckpointCallback
|
||||
from ray.data.datasource import SimpleTensorFlowDatasource
|
||||
from ray.data.extensions import TensorArray
|
||||
from ray.train import Result, ScalingConfig
|
||||
from ray.train.tensorflow import TensorflowTrainer, prepare_dataset_shard
|
||||
|
||||
|
||||
def get_dataset(split_type="train"):
|
||||
def dataset_factory():
|
||||
return tfds.load("mnist", split=[split_type], as_supervised=True)[0].take(128)
|
||||
|
||||
dataset = ray.data.read_datasource(
|
||||
SimpleTensorFlowDatasource(), dataset_factory=dataset_factory
|
||||
)
|
||||
|
||||
def normalize_images(x):
|
||||
x = np.float32(x.numpy()) / 255.0
|
||||
x = np.reshape(x, (-1,))
|
||||
return x
|
||||
|
||||
def preprocess_dataset(batch):
|
||||
return [
|
||||
(normalize_images(image), normalize_images(image)) for image, _ in batch
|
||||
]
|
||||
|
||||
dataset = dataset.map_batches(preprocess_dataset)
|
||||
|
||||
def convert_batch_to_pandas(batch):
|
||||
|
||||
images = [TensorArray(image) for image, _ in batch]
|
||||
# because we did autoencoder here
|
||||
df = pd.DataFrame({"image": images, "label": images})
|
||||
return df
|
||||
|
||||
dataset = dataset.map_batches(convert_batch_to_pandas)
|
||||
return dataset
|
||||
|
||||
|
||||
def build_autoencoder_model() -> tf.keras.Model:
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.Input(shape=(784,)),
|
||||
# encoder
|
||||
tf.keras.layers.Dense(128, activation="relu"),
|
||||
tf.keras.layers.Dense(64, activation="relu"),
|
||||
tf.keras.layers.Dense(32, activation="relu"),
|
||||
# decoder
|
||||
tf.keras.layers.Dense(64, activation="relu"),
|
||||
tf.keras.layers.Dense(128, activation="relu"),
|
||||
tf.keras.layers.Dense(784, activation="sigmoid"),
|
||||
]
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def train_func(config: dict):
|
||||
|
||||
per_worker_batch_size = config.get("batch_size", 64)
|
||||
epochs = config.get("epochs", 3)
|
||||
|
||||
dataset_shard = train.get_dataset_shard("train")
|
||||
|
||||
strategy = tf.distribute.MultiWorkerMirroredStrategy()
|
||||
|
||||
with strategy.scope():
|
||||
# Model building/compiling need to be within `strategy.scope()`.
|
||||
multi_worker_model = build_autoencoder_model()
|
||||
learning_rate = config.get("lr", 0.001)
|
||||
multi_worker_model.compile(
|
||||
loss=tf.keras.losses.BinaryCrossentropy(),
|
||||
optimizer=tf.keras.optimizers.Adam(learning_rate=learning_rate),
|
||||
metrics=[
|
||||
"binary_crossentropy",
|
||||
],
|
||||
)
|
||||
|
||||
def to_tf_dataset(dataset, batch_size):
|
||||
def to_tensor_iterator():
|
||||
for batch in dataset.iter_tf_batches(
|
||||
batch_size=batch_size, dtypes=tf.float32
|
||||
):
|
||||
yield batch["image"], batch["label"]
|
||||
|
||||
output_signature = (
|
||||
tf.TensorSpec(shape=(None, 784), dtype=tf.float32),
|
||||
tf.TensorSpec(shape=(None, 784), dtype=tf.float32),
|
||||
)
|
||||
tf_dataset = tf.data.Dataset.from_generator(
|
||||
to_tensor_iterator, output_signature=output_signature
|
||||
)
|
||||
return prepare_dataset_shard(tf_dataset)
|
||||
|
||||
results = []
|
||||
for epoch in range(epochs):
|
||||
tf_dataset = to_tf_dataset(
|
||||
dataset=dataset_shard,
|
||||
batch_size=per_worker_batch_size,
|
||||
)
|
||||
history = multi_worker_model.fit(
|
||||
tf_dataset, callbacks=[ReportCheckpointCallback()]
|
||||
)
|
||||
results.append(history.history)
|
||||
return results
|
||||
|
||||
|
||||
def train_tensorflow_mnist(
|
||||
num_workers: int = 2, use_gpu: bool = False, epochs: int = 4
|
||||
) -> Result:
|
||||
train_dataset = get_dataset(split_type="train")
|
||||
config = {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
scaling_config = ScalingConfig(num_workers=num_workers, use_gpu=use_gpu)
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
datasets={"train": train_dataset},
|
||||
scaling_config=scaling_config,
|
||||
)
|
||||
|
||||
results = trainer.fit()
|
||||
print(results.metrics)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="Enables GPU training"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs", type=int, default=3, help="Number of epochs to train for."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Finish quickly for testing.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.smoke_test:
|
||||
# 2 workers, 1 for trainer, 1 for datasets
|
||||
num_gpus = args.num_workers if args.use_gpu else 0
|
||||
ray.init(num_cpus=4, num_gpus=num_gpus)
|
||||
result = train_tensorflow_mnist(num_workers=2, use_gpu=args.use_gpu)
|
||||
else:
|
||||
ray.init(address=args.address)
|
||||
result = train_tensorflow_mnist(
|
||||
num_workers=args.num_workers, use_gpu=args.use_gpu, epochs=args.epochs
|
||||
)
|
||||
print(result)
|
||||
@@ -0,0 +1,138 @@
|
||||
# This example showcases how to use Tensorflow with Ray Train.
|
||||
# Original code:
|
||||
# https://www.tensorflow.org/tutorials/distribute/multi_worker_with_keras
|
||||
import os
|
||||
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
from filelock import FileLock
|
||||
|
||||
from ray.air.integrations.keras import ReportCheckpointCallback
|
||||
from ray.train import Result, RunConfig, ScalingConfig
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
|
||||
def mnist_dataset(batch_size: int) -> tf.data.Dataset:
|
||||
with FileLock(os.path.expanduser("~/.mnist_lock")):
|
||||
(x_train, y_train), _ = tf.keras.datasets.mnist.load_data()
|
||||
# The `x` arrays are in uint8 and have values in the [0, 255] range.
|
||||
# You need to convert them to float32 with values in the [0, 1] range.
|
||||
x_train = x_train / np.float32(255)
|
||||
y_train = y_train.astype(np.int64)
|
||||
train_dataset = (
|
||||
tf.data.Dataset.from_tensor_slices((x_train, y_train))
|
||||
.shuffle(60000)
|
||||
.repeat()
|
||||
.batch(batch_size)
|
||||
)
|
||||
return train_dataset
|
||||
|
||||
|
||||
def build_cnn_model() -> tf.keras.Model:
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.Input(shape=(28, 28)),
|
||||
tf.keras.layers.Reshape(target_shape=(28, 28, 1)),
|
||||
tf.keras.layers.Conv2D(32, 3, activation="relu"),
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(128, activation="relu"),
|
||||
tf.keras.layers.Dense(10),
|
||||
]
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def train_func(config: dict):
|
||||
per_worker_batch_size = config.get("batch_size", 64)
|
||||
epochs = config.get("epochs", 3)
|
||||
steps_per_epoch = config.get("steps_per_epoch", 70)
|
||||
|
||||
tf_config = json.loads(os.environ["TF_CONFIG"])
|
||||
num_workers = len(tf_config["cluster"]["worker"])
|
||||
|
||||
strategy = tf.distribute.MultiWorkerMirroredStrategy()
|
||||
|
||||
global_batch_size = per_worker_batch_size * num_workers
|
||||
multi_worker_dataset = mnist_dataset(global_batch_size)
|
||||
|
||||
with strategy.scope():
|
||||
# Model building/compiling need to be within `strategy.scope()`.
|
||||
multi_worker_model = build_cnn_model()
|
||||
learning_rate = config.get("lr", 0.001)
|
||||
multi_worker_model.compile(
|
||||
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
|
||||
optimizer=tf.keras.optimizers.SGD(learning_rate=learning_rate),
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
|
||||
history = multi_worker_model.fit(
|
||||
multi_worker_dataset,
|
||||
epochs=epochs,
|
||||
steps_per_epoch=steps_per_epoch,
|
||||
callbacks=[ReportCheckpointCallback()],
|
||||
)
|
||||
results = history.history
|
||||
return results
|
||||
|
||||
|
||||
def train_tensorflow_mnist(
|
||||
num_workers: int = 2,
|
||||
use_gpu: bool = False,
|
||||
epochs: int = 4,
|
||||
storage_path: str = None,
|
||||
) -> Result:
|
||||
config = {"lr": 1e-3, "batch_size": 64, "epochs": epochs}
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers, use_gpu=use_gpu),
|
||||
run_config=RunConfig(storage_path=storage_path),
|
||||
)
|
||||
results = trainer.fit()
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="Enables GPU training"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epochs", type=int, default=3, help="Number of epochs to train for."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Finish quickly for testing.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
import ray
|
||||
|
||||
if args.smoke_test:
|
||||
# 2 workers, 1 for trainer, 1 for datasets
|
||||
num_gpus = args.num_workers if args.use_gpu else 0
|
||||
ray.init(num_cpus=4, num_gpus=num_gpus)
|
||||
train_tensorflow_mnist(num_workers=2, use_gpu=args.use_gpu)
|
||||
else:
|
||||
ray.init(address=args.address)
|
||||
train_tensorflow_mnist(
|
||||
num_workers=args.num_workers, use_gpu=args.use_gpu, epochs=args.epochs
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
# ruff: noqa
|
||||
# fmt: off
|
||||
# isort: skip_file
|
||||
|
||||
# __tf_setup_begin__
|
||||
import os
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import sys
|
||||
import numpy as np
|
||||
|
||||
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
|
||||
|
||||
def mnist_dataset(batch_size):
|
||||
(x_train, y_train), _ = tf.keras.datasets.mnist.load_data()
|
||||
# The `x` arrays are in uint8 and have values in the [0, 255] range.
|
||||
# You need to convert them to float32 with values in the [0, 1] range.
|
||||
x_train = x_train / np.float32(255)
|
||||
y_train = y_train.astype(np.int64)
|
||||
train_dataset = tf.data.Dataset.from_tensor_slices(
|
||||
(x_train, y_train)).shuffle(60000).repeat().batch(batch_size)
|
||||
return train_dataset
|
||||
|
||||
|
||||
def build_and_compile_cnn_model():
|
||||
model = tf.keras.Sequential([
|
||||
tf.keras.layers.InputLayer(input_shape=(28, 28)),
|
||||
tf.keras.layers.Reshape(target_shape=(28, 28, 1)),
|
||||
tf.keras.layers.Conv2D(32, 3, activation='relu'),
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(128, activation='relu'),
|
||||
tf.keras.layers.Dense(10)
|
||||
])
|
||||
model.compile(
|
||||
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
|
||||
optimizer=tf.keras.optimizers.SGD(learning_rate=0.001),
|
||||
metrics=['accuracy'])
|
||||
return model
|
||||
# __tf_setup_end__
|
||||
|
||||
# __tf_single_begin__
|
||||
def train_func():
|
||||
batch_size = 64
|
||||
single_worker_dataset = mnist_dataset(batch_size)
|
||||
single_worker_model = build_and_compile_cnn_model()
|
||||
single_worker_model.fit(single_worker_dataset, epochs=3, steps_per_epoch=70)
|
||||
# __tf_single_end__
|
||||
|
||||
# __tf_distributed_begin__
|
||||
import json
|
||||
import os
|
||||
|
||||
def train_func_distributed():
|
||||
per_worker_batch_size = 64
|
||||
# This environment variable will be set by Ray Train.
|
||||
tf_config = json.loads(os.environ['TF_CONFIG'])
|
||||
num_workers = len(tf_config['cluster']['worker'])
|
||||
|
||||
strategy = tf.distribute.MultiWorkerMirroredStrategy()
|
||||
|
||||
global_batch_size = per_worker_batch_size * num_workers
|
||||
multi_worker_dataset = mnist_dataset(global_batch_size)
|
||||
|
||||
with strategy.scope():
|
||||
# Model building/compiling need to be within `strategy.scope()`.
|
||||
multi_worker_model = build_and_compile_cnn_model()
|
||||
|
||||
multi_worker_model.fit(multi_worker_dataset, epochs=3, steps_per_epoch=70)
|
||||
# __tf_distributed_end__
|
||||
|
||||
if __name__ == "__main__":
|
||||
# __tf_single_run_begin__
|
||||
train_func()
|
||||
# __tf_single_run_end__
|
||||
|
||||
# __tf_trainer_begin__
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
from ray.train import ScalingConfig
|
||||
|
||||
# For GPU Training, set `use_gpu` to True.
|
||||
use_gpu = False
|
||||
|
||||
trainer = TensorflowTrainer(train_func_distributed, scaling_config=ScalingConfig(num_workers=4, use_gpu=use_gpu))
|
||||
|
||||
trainer.fit()
|
||||
# __tf_trainer_end__
|
||||
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.data.preprocessors import Concatenator
|
||||
from ray.train import Result, ScalingConfig
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
# Skip this test in Python 3.12+ because TensorFlow is not supported.
|
||||
sys.exit(0)
|
||||
else:
|
||||
import tensorflow as tf
|
||||
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
from ray.train.tensorflow.keras import ReportCheckpointCallback
|
||||
|
||||
|
||||
def build_model() -> tf.keras.Model:
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.InputLayer(input_shape=(100,)),
|
||||
tf.keras.layers.Dense(10),
|
||||
tf.keras.layers.Dense(1),
|
||||
]
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def train_func(config: dict):
|
||||
batch_size = config.get("batch_size", 64)
|
||||
epochs = config.get("epochs", 3)
|
||||
|
||||
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_absolute_error,
|
||||
metrics=[tf.keras.metrics.mean_squared_error],
|
||||
)
|
||||
|
||||
dataset = train.get_dataset_shard("train")
|
||||
|
||||
results = []
|
||||
for _ in range(epochs):
|
||||
tf_dataset = dataset.to_tf(
|
||||
feature_columns="x", label_columns="y", batch_size=batch_size
|
||||
)
|
||||
history = multi_worker_model.fit(
|
||||
tf_dataset, callbacks=[ReportCheckpointCallback()]
|
||||
)
|
||||
results.append(history.history)
|
||||
return results
|
||||
|
||||
|
||||
def train_tensorflow_regression(num_workers: int = 2, use_gpu: bool = False) -> Result:
|
||||
dataset = ray.data.read_csv("s3://anonymous@air-example-data/regression.csv")
|
||||
columns_to_concatenate = [f"x{i:03}" for i in range(100)]
|
||||
preprocessor = Concatenator(columns=columns_to_concatenate, output_column_name="x")
|
||||
dataset = preprocessor.fit_transform(dataset)
|
||||
|
||||
config = {"lr": 1e-3, "batch_size": 32, "epochs": 4}
|
||||
scaling_config = ScalingConfig(num_workers=num_workers, use_gpu=use_gpu)
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=scaling_config,
|
||||
datasets={"train": dataset},
|
||||
)
|
||||
results = trainer.fit()
|
||||
print(results.metrics)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--address", required=False, type=str, help="the address to use for Ray"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
"-n",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Sets number of workers for training.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-gpu", action="store_true", default=False, help="Enables GPU training"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Finish quickly for testing.",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.smoke_test:
|
||||
# 2 workers, 1 for trainer, 1 for datasets
|
||||
num_gpus = args.num_workers if args.use_gpu else 0
|
||||
ray.init(num_cpus=4, num_gpus=num_gpus)
|
||||
result = train_tensorflow_regression(num_workers=2, use_gpu=args.use_gpu)
|
||||
else:
|
||||
ray.init(address=args.address)
|
||||
result = train_tensorflow_regression(
|
||||
num_workers=args.num_workers, use_gpu=args.use_gpu
|
||||
)
|
||||
print(result)
|
||||
@@ -0,0 +1,78 @@
|
||||
import evaluate
|
||||
import numpy as np
|
||||
|
||||
# Minimal Example adapted from https://huggingface.co/docs/transformers/training
|
||||
from datasets import load_dataset
|
||||
from transformers import (
|
||||
AutoModelForSequenceClassification,
|
||||
AutoTokenizer,
|
||||
Trainer,
|
||||
TrainingArguments,
|
||||
)
|
||||
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.huggingface.transformers import RayTrainReportCallback, prepare_trainer
|
||||
from ray.train.torch import TorchTrainer
|
||||
|
||||
|
||||
# [1] Define a training function that includes all your training logic
|
||||
# ====================================================================
|
||||
def train_func(config):
|
||||
# Datasets
|
||||
dataset = load_dataset("Yelp/yelp_review_full")
|
||||
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
|
||||
|
||||
def tokenize_function(examples):
|
||||
return tokenizer(examples["text"], padding="max_length", truncation=True)
|
||||
|
||||
tokenized_ds = dataset.map(tokenize_function, batched=True)
|
||||
|
||||
small_train_ds = tokenized_ds["train"].shuffle(seed=42).select(range(1000))
|
||||
small_eval_ds = tokenized_ds["test"].shuffle(seed=42).select(range(1000))
|
||||
|
||||
# Model
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
"bert-base-cased", num_labels=5
|
||||
)
|
||||
|
||||
# Evaluation metrics
|
||||
metric = evaluate.load("accuracy")
|
||||
|
||||
def compute_metrics(eval_pred):
|
||||
logits, labels = eval_pred
|
||||
predictions = np.argmax(logits, axis=-1)
|
||||
return metric.compute(predictions=predictions, references=labels)
|
||||
|
||||
# Hugging Face Trainer
|
||||
training_args = TrainingArguments(
|
||||
output_dir="test_trainer", eval_strategy="epoch", report_to="none"
|
||||
)
|
||||
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
args=training_args,
|
||||
train_dataset=small_train_ds,
|
||||
eval_dataset=small_eval_ds,
|
||||
compute_metrics=compute_metrics,
|
||||
)
|
||||
|
||||
# [2] Report metrics and checkpoints to Ray Train
|
||||
# ===============================================
|
||||
trainer.add_callback(RayTrainReportCallback())
|
||||
|
||||
# [3] Prepare your trainer for Ray Data integration
|
||||
# =================================================
|
||||
trainer = prepare_trainer(trainer)
|
||||
|
||||
# Start Training
|
||||
trainer.train()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# [4] Build a Ray TorchTrainer to launch `train_func` on all workers
|
||||
# ==================================================================
|
||||
trainer = TorchTrainer(
|
||||
train_func, scaling_config=ScalingConfig(num_workers=4, use_gpu=True)
|
||||
)
|
||||
|
||||
trainer.fit()
|
||||
@@ -0,0 +1,22 @@
|
||||
# isort: off
|
||||
try:
|
||||
import horovod # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"Horovod isn't installed. To install Horovod with PyTorch support, run 'pip "
|
||||
"install 'horovod[pytorch]''. To install Horovod with TensorFlow support, "
|
||||
"run 'pip install 'horovod[tensorflow]''."
|
||||
)
|
||||
# isort: on
|
||||
|
||||
from ray.train.horovod.config import HorovodConfig
|
||||
from ray.train.horovod.horovod_trainer import HorovodTrainer
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
from ray.train.v2.horovod.horovod_trainer import HorovodTrainer # noqa: F811
|
||||
|
||||
__all__ = ["HorovodConfig", "HorovodTrainer"]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,163 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Set
|
||||
|
||||
from horovod.ray.runner import Coordinator
|
||||
from horovod.ray.utils import detect_nics, nics_to_env_var
|
||||
from horovod.runner.common.util import secret, timeout
|
||||
|
||||
import ray
|
||||
from ray.train._internal.utils import update_env_vars
|
||||
from ray.train._internal.worker_group import Worker, WorkerGroup
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.util import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
@dataclass
|
||||
class HorovodConfig(BackendConfig):
|
||||
"""Configurations for Horovod setup.
|
||||
|
||||
See https://github.com/horovod/horovod/blob/master/horovod/runner/common/util/settings.py # noqa: E501
|
||||
|
||||
Args:
|
||||
nics (Optional[Set[str]): Network interfaces that can be used for
|
||||
communication.
|
||||
verbose: Horovod logging verbosity.
|
||||
key (Optional[str]): Secret used for communication between workers.
|
||||
ssh_port (Optional[int]): Port for SSH server running on worker nodes.
|
||||
ssh_identity_file (Optional[str]): Path to the identity file to
|
||||
ssh into different hosts on the cluster.
|
||||
ssh_str (Optional[str]): CAUTION WHEN USING THIS. Private key
|
||||
file contents. Writes the private key to ssh_identity_file.
|
||||
timeout_s: Timeout parameter for Gloo rendezvous.
|
||||
placement_group_timeout_s: Timeout parameter for Ray
|
||||
Placement Group creation. Currently unused.
|
||||
"""
|
||||
|
||||
nics: Optional[Set[str]] = None
|
||||
verbose: int = 1
|
||||
key: Optional[str] = None
|
||||
ssh_port: Optional[int] = None
|
||||
ssh_identity_file: Optional[str] = None
|
||||
ssh_str: Optional[str] = None
|
||||
timeout_s: int = 300
|
||||
placement_group_timeout_s: int = 100
|
||||
|
||||
@property
|
||||
def start_timeout(self):
|
||||
return timeout.Timeout(
|
||||
self.timeout_s,
|
||||
message="Timed out waiting for {activity}. Please "
|
||||
"check connectivity between servers. You "
|
||||
"may need to increase the --start-timeout "
|
||||
"parameter if you have too many servers.",
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ssh_str and not os.path.exists(self.ssh_identity_file):
|
||||
with open(self.ssh_identity_file, "w") as f:
|
||||
os.chmod(self.ssh_identity_file, 0o600)
|
||||
f.write(self.ssh_str)
|
||||
|
||||
if self.key is None:
|
||||
self.key = secret.make_secret_key()
|
||||
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return _HorovodBackend
|
||||
|
||||
|
||||
class _HorovodBackend(Backend):
|
||||
share_cuda_visible_devices: bool = True
|
||||
|
||||
def on_start(self, worker_group: WorkerGroup, backend_config: HorovodConfig):
|
||||
# NOTE: Horovod backend uses V1 WorkerGroup directly instead of BaseWorkerGroup
|
||||
# because it requires direct access to worker metadata (node_id, hostname) that is
|
||||
# specific to the V1 implementation. Horovod does not support V2 WorkerGroup.
|
||||
|
||||
# TODO(matt): Implement placement group strategies in BackendExecutor.
|
||||
|
||||
# Initialize workers with Horovod environment variables
|
||||
setup_futures = []
|
||||
for rank in range(len(worker_group)):
|
||||
worker_node_id = worker_group.workers[rank].metadata.node_id
|
||||
setup_futures.append(
|
||||
worker_group.execute_single_async(
|
||||
rank,
|
||||
_init_env_vars,
|
||||
rank,
|
||||
len(worker_group),
|
||||
worker_node_id,
|
||||
)
|
||||
)
|
||||
ray.get(setup_futures)
|
||||
|
||||
# Use Horovod Ray Coordinator
|
||||
# backend_config as settings
|
||||
self.coordinator = Coordinator(backend_config)
|
||||
|
||||
# Get all the hostnames of all workers
|
||||
node_ids = [w.metadata.node_id for w in worker_group.workers]
|
||||
hostnames = [w.metadata.hostname for w in worker_group.workers]
|
||||
# Register each hostname to the coordinator. assumes the hostname
|
||||
# ordering is the same.
|
||||
for rank, (hostname, node_id) in enumerate(zip(hostnames, node_ids)):
|
||||
self.coordinator.register(hostname, node_id, rank)
|
||||
all_info = self.coordinator.finalize_registration()
|
||||
|
||||
setup_futures = []
|
||||
for rank, local_cross_env_var in all_info.items():
|
||||
setup_futures.append(
|
||||
worker_group.execute_single_async(
|
||||
rank, update_env_vars, local_cross_env_var
|
||||
)
|
||||
)
|
||||
ray.get(setup_futures)
|
||||
|
||||
coordinator_envs = self.coordinator.establish_rendezvous()
|
||||
|
||||
# Get one worker from each host/node.
|
||||
node_worker_indexes = [node_ids.index(node_id) for node_id in set(node_ids)]
|
||||
node_workers = [
|
||||
_HorovodWorkerWrapper(worker_group.workers[worker_index])
|
||||
for worker_index in node_worker_indexes
|
||||
]
|
||||
assert len(node_workers) == len(self.coordinator.hostnames)
|
||||
|
||||
nics = detect_nics(
|
||||
backend_config,
|
||||
all_host_names=list(self.coordinator.hostnames),
|
||||
node_workers=node_workers,
|
||||
)
|
||||
coordinator_envs.update(nics_to_env_var(nics))
|
||||
|
||||
worker_group.execute(update_env_vars, coordinator_envs)
|
||||
|
||||
|
||||
def _init_env_vars(world_rank: int, world_size: int, node_id: str):
|
||||
"""Initialize Horovod environment variables."""
|
||||
os.environ["HOROVOD_HOSTNAME"] = node_id
|
||||
os.environ["HOROVOD_RANK"] = str(world_rank)
|
||||
os.environ["HOROVOD_SIZE"] = str(world_size)
|
||||
|
||||
|
||||
# TODO(tgaddair): temporary workaround for Horovod's worker discovery logic,
|
||||
# which requires passing in an extra parameter as part of the RayExecutor
|
||||
# API. This will be removed in the future as we migrate more of the
|
||||
# RayExecutor utils into Ray Train.
|
||||
# See: https://github.com/horovod/horovod/blob/v0.23.0/horovod/ray/driver_service.py#L9 # noqa: E501
|
||||
@dataclass
|
||||
class _HorovodWorkerWrapper:
|
||||
w: Worker
|
||||
|
||||
@property
|
||||
def execute(self):
|
||||
w = self.w
|
||||
|
||||
class ExecuteHandle:
|
||||
def remote(self, func, *args, **kwargs):
|
||||
_ = None
|
||||
return w.actor._RayTrainWorker__execute.remote(func, _, *args, **kwargs)
|
||||
|
||||
return ExecuteHandle()
|
||||
@@ -0,0 +1,197 @@
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
|
||||
from ray.air.config import RunConfig, ScalingConfig
|
||||
from ray.train import Checkpoint, DataConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.horovod.config import HorovodConfig
|
||||
from ray.train.trainer import GenDataset
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class HorovodTrainer(DataParallelTrainer):
|
||||
"""A Trainer for data parallel Horovod training.
|
||||
|
||||
This Trainer runs the function ``train_loop_per_worker`` on multiple Ray
|
||||
Actors. These actors already have the necessary Horovod setup already
|
||||
configured for distributed Horovod training.
|
||||
|
||||
The ``train_loop_per_worker`` function is expected to take in either 0 or 1
|
||||
arguments:
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker():
|
||||
...
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker(config: Dict):
|
||||
...
|
||||
|
||||
If ``train_loop_per_worker`` accepts an argument, then
|
||||
``train_loop_config`` will be passed in as the argument. This is useful if you
|
||||
want to tune the values in ``train_loop_config`` as hyperparameters.
|
||||
|
||||
If the ``datasets`` dict contains a training dataset (denoted by
|
||||
the "train" key), then it will be split into multiple dataset
|
||||
shards that can then be accessed by ``ray.train.get_dataset_shard("train")`` inside
|
||||
``train_loop_per_worker``. All the other datasets will not be split and
|
||||
``ray.train.get_dataset_shard(...)`` will return the entire Dataset.
|
||||
|
||||
Inside the ``train_loop_per_worker`` function, you can use any of the
|
||||
:ref:`Ray Train loop methods <train-loop-api>`.
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray import train
|
||||
|
||||
def train_loop_per_worker():
|
||||
# Report intermediate results for callbacks or logging and
|
||||
# checkpoint data.
|
||||
train.report(...)
|
||||
|
||||
# Returns dict of last saved checkpoint.
|
||||
train.get_checkpoint()
|
||||
|
||||
# Returns the Dataset shard for the given key.
|
||||
train.get_dataset_shard("my_dataset")
|
||||
|
||||
# Returns the total number of workers executing training.
|
||||
train.get_context().get_world_size()
|
||||
|
||||
# Returns the rank of this worker.
|
||||
train.get_context().get_world_rank()
|
||||
|
||||
# Returns the rank of the worker on the current node.
|
||||
train.get_context().get_local_rank()
|
||||
|
||||
Any returns from the ``train_loop_per_worker`` will be discarded and not
|
||||
used or persisted anywhere.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import ray
|
||||
import horovod.torch as hvd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from ray import train
|
||||
import ray.train.torch # Need this to use `train.torch.get_device()`
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.horovod import HorovodTrainer
|
||||
|
||||
# If using GPUs, set this to True.
|
||||
use_gpu = False
|
||||
|
||||
input_size = 1
|
||||
layer_size = 15
|
||||
output_size = 1
|
||||
num_epochs = 3
|
||||
|
||||
class NeuralNetwork(nn.Module):
|
||||
def __init__(self):
|
||||
super(NeuralNetwork, self).__init__()
|
||||
self.layer1 = nn.Linear(input_size, layer_size)
|
||||
self.relu = nn.ReLU()
|
||||
self.layer2 = nn.Linear(layer_size, output_size)
|
||||
def forward(self, input):
|
||||
return self.layer2(self.relu(self.layer1(input)))
|
||||
|
||||
def train_loop_per_worker():
|
||||
hvd.init()
|
||||
dataset_shard = train.get_dataset_shard("train")
|
||||
model = NeuralNetwork()
|
||||
device = train.torch.get_device()
|
||||
model.to(device)
|
||||
loss_fn = nn.MSELoss()
|
||||
lr_scaler = 1
|
||||
optimizer = torch.optim.SGD(model.parameters(), lr=0.1 * lr_scaler)
|
||||
# Horovod: wrap optimizer with DistributedOptimizer.
|
||||
optimizer = hvd.DistributedOptimizer(
|
||||
optimizer,
|
||||
named_parameters=model.named_parameters(),
|
||||
op=hvd.Average,
|
||||
)
|
||||
for epoch in range(num_epochs):
|
||||
model.train()
|
||||
for batch in dataset_shard.iter_torch_batches(
|
||||
batch_size=32, dtypes=torch.float
|
||||
):
|
||||
inputs, labels = torch.unsqueeze(batch["x"], 1), batch["y"]
|
||||
outputs = model(inputs)
|
||||
loss = loss_fn(outputs, labels)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
print(f"epoch: {epoch}, loss: {loss.item()}")
|
||||
|
||||
# Save a model checkpoint at the end of each epoch
|
||||
with tempfile.TemporaryDirectory() as temp_checkpoint_dir:
|
||||
ckpt_path = os.path.join(temp_checkpoint_dir, "model.pt")
|
||||
torch.save(model.state_dict(), ckpt_path)
|
||||
train.report(
|
||||
{"loss": loss.item(), "epoch": epoch},
|
||||
checkpoint=Checkpoint.from_directory(temp_checkpoint_dir),
|
||||
)
|
||||
|
||||
train_dataset = ray.data.from_items([{"x": x, "y": x + 1} for x in range(32)])
|
||||
scaling_config = ScalingConfig(num_workers=3, use_gpu=use_gpu)
|
||||
trainer = HorovodTrainer(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
scaling_config=scaling_config,
|
||||
datasets={"train": train_dataset},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute.
|
||||
This can either take in no arguments or a ``config`` dict.
|
||||
train_loop_config: Configurations to pass into
|
||||
``train_loop_per_worker`` if it accepts an argument.
|
||||
horovod_config: Configuration for setting up the Horovod backend.
|
||||
If set to None, use the default configuration. This replaces the
|
||||
``backend_config`` arg of ``DataParallelTrainer``.
|
||||
scaling_config: Configuration for how to scale data parallel training.
|
||||
dataset_config: Configuration for dataset ingest.
|
||||
run_config: Configuration for the execution of the training run.
|
||||
datasets: Any Datasets to use for training. Use
|
||||
the key "train" to denote which dataset is the training
|
||||
dataset.
|
||||
metadata: Dict that should be made available via
|
||||
`ray.train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Union[Callable[[], None], Callable[[Dict], None]],
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
horovod_config: Optional[HorovodConfig] = None,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
dataset_config: Optional[DataConfig] = None,
|
||||
run_config: Optional[RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
super().__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
backend_config=horovod_config or HorovodConfig(),
|
||||
scaling_config=scaling_config,
|
||||
dataset_config=dataset_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
from ray.train.huggingface.transformers._transformers_utils import (
|
||||
RayTrainReportCallback,
|
||||
prepare_trainer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RayTrainReportCallback",
|
||||
"prepare_trainer",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Iterator, Optional, Type, Union
|
||||
|
||||
from torch.utils.data import DataLoader, Dataset, IterableDataset
|
||||
|
||||
import ray
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
from ray.train import Checkpoint
|
||||
from ray.util import PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TRANSFORMERS_IMPORT_ERROR: Optional[ImportError] = None
|
||||
|
||||
try:
|
||||
import transformers.trainer
|
||||
from transformers import Trainer
|
||||
from transformers.trainer_callback import TrainerCallback
|
||||
except ImportError as e:
|
||||
TRANSFORMERS_IMPORT_ERROR = e
|
||||
TrainerCallback = object
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayTrainReportCallback(TrainerCallback):
|
||||
"""A simple callback to report checkpoints and metrics to Ray Train.
|
||||
|
||||
This callback is a subclass of `transformers.TrainerCallback
|
||||
<https://huggingface.co/docs/transformers/main/en/main_classes/callback#transformers.TrainerCallback>`_
|
||||
and overrides the `TrainerCallback.on_save()` method. After
|
||||
a new checkpoint get saved, it fetches the latest metric dictionary
|
||||
from `TrainerState.log_history` and reports it with the latest checkpoint
|
||||
to Ray Train.
|
||||
|
||||
Checkpoints will be saved in the following structure::
|
||||
|
||||
checkpoint_00000*/ Ray Train Checkpoint
|
||||
└─ checkpoint/ Hugging Face Transformers Checkpoint
|
||||
|
||||
For customized reporting and checkpointing logic, implement your own
|
||||
`transformers.TrainerCallback` following this user
|
||||
guide: :ref:`Saving and Loading Checkpoints <train-dl-saving-checkpoints>`.
|
||||
|
||||
Note that users should ensure that the logging, evaluation, and saving frequencies
|
||||
are properly configured so that the monitoring metric is always up-to-date
|
||||
when `transformers.Trainer` saves a checkpoint.
|
||||
|
||||
Suppose the monitoring metric is reported from evaluation stage:
|
||||
|
||||
Some valid configurations:
|
||||
- evaluation_strategy == save_strategy == "epoch"
|
||||
- evaluation_strategy == save_strategy == "steps", save_steps % eval_steps == 0
|
||||
|
||||
Some invalid configurations:
|
||||
- evaluation_strategy != save_strategy
|
||||
- evaluation_strategy == save_strategy == "steps", save_steps % eval_steps != 0
|
||||
|
||||
"""
|
||||
|
||||
CHECKPOINT_NAME = "checkpoint"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
record_extra_usage_tag(TagKey.TRAIN_TRANSFORMERS_RAYTRAINREPORTCALLBACK, "1")
|
||||
|
||||
def on_save(self, args, state, control, **kwargs):
|
||||
"""Event called after a checkpoint save."""
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
# Aggregate all the logged metrics
|
||||
metrics = {}
|
||||
for log in state.log_history:
|
||||
metrics.update(log)
|
||||
|
||||
# Copy ckpt files and construct a Ray Train Checkpoint
|
||||
source_ckpt_path = transformers.trainer.get_last_checkpoint(args.output_dir)
|
||||
if source_ckpt_path is not None:
|
||||
target_ckpt_path = Path(tmpdir, self.CHECKPOINT_NAME).as_posix()
|
||||
shutil.copytree(source_ckpt_path, target_ckpt_path)
|
||||
checkpoint = Checkpoint.from_directory(tmpdir)
|
||||
else:
|
||||
checkpoint = None
|
||||
|
||||
# Report latest metrics and checkpoint to Ray Train
|
||||
ray.train.report(metrics=metrics, checkpoint=checkpoint)
|
||||
|
||||
|
||||
class RayTorchIterableDataset(IterableDataset):
|
||||
"""Wrapper class for ray data iterables."""
|
||||
|
||||
def __init__(self, data_iterable) -> None:
|
||||
super().__init__()
|
||||
self.data_iterable = data_iterable
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self.data_iterable)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def prepare_trainer(trainer: "Trainer") -> "Trainer":
|
||||
"""Prepare your HuggingFace Transformer Trainer for Ray Train.
|
||||
|
||||
This utility function enable the trainer integrates with Ray Data Integration.
|
||||
Internally, it overrides the `get_train_dataloader` and `get_eval_dataloader`
|
||||
methods and inject the data integration logics if the `train_dataset` and
|
||||
`eval_dataset` are Ray Data Iterables.
|
||||
"""
|
||||
from ray.data.iterator import _IterableFromIterator
|
||||
|
||||
if TRANSFORMERS_IMPORT_ERROR is not None:
|
||||
raise TRANSFORMERS_IMPORT_ERROR
|
||||
|
||||
base_trainer_class: Type[transformers.trainer.Trainer] = trainer.__class__
|
||||
|
||||
class RayTransformersTrainer(base_trainer_class):
|
||||
"""A Wrapper of `transformers.Trainer` for Ray Data Integration."""
|
||||
|
||||
def get_train_dataloader(self) -> DataLoader:
|
||||
if isinstance(self.train_dataset, _IterableFromIterator):
|
||||
dataset = RayTorchIterableDataset(self.train_dataset)
|
||||
return DataLoader(dataset, batch_size=1, collate_fn=lambda x: x[0])
|
||||
else:
|
||||
return super().get_train_dataloader()
|
||||
|
||||
def get_eval_dataloader(
|
||||
self, eval_dataset: Optional[Union[str, Dataset]] = None
|
||||
) -> DataLoader:
|
||||
if eval_dataset is None:
|
||||
eval_dataset = self.eval_dataset
|
||||
|
||||
if (
|
||||
isinstance(eval_dataset, str)
|
||||
and isinstance(self.eval_dataset, dict)
|
||||
and isinstance(self.eval_dataset[eval_dataset], _IterableFromIterator)
|
||||
):
|
||||
dataset = RayTorchIterableDataset(self.eval_dataset[eval_dataset])
|
||||
return DataLoader(dataset, batch_size=1, collate_fn=lambda x: x[0])
|
||||
elif isinstance(eval_dataset, _IterableFromIterator):
|
||||
dataset = RayTorchIterableDataset(eval_dataset)
|
||||
return DataLoader(dataset, batch_size=1, collate_fn=lambda x: x[0])
|
||||
else:
|
||||
return super().get_eval_dataloader(eval_dataset)
|
||||
|
||||
trainer.__class__ = RayTransformersTrainer
|
||||
|
||||
record_extra_usage_tag(TagKey.TRAIN_TRANSFORMERS_PREPARE_TRAINER, "1")
|
||||
return trainer
|
||||
@@ -0,0 +1,23 @@
|
||||
from ray.train.lightgbm._lightgbm_utils import (
|
||||
RayTrainReportCallback,
|
||||
normalize_pandas_for_lightgbm,
|
||||
)
|
||||
from ray.train.lightgbm.config import LightGBMConfig, get_network_params
|
||||
from ray.train.lightgbm.lightgbm_checkpoint import LightGBMCheckpoint
|
||||
from ray.train.lightgbm.lightgbm_trainer import LightGBMTrainer
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
from ray.train.v2.lightgbm.lightgbm_trainer import LightGBMTrainer # noqa: F811
|
||||
|
||||
__all__ = [
|
||||
"RayTrainReportCallback",
|
||||
"LightGBMCheckpoint",
|
||||
"LightGBMTrainer",
|
||||
"LightGBMConfig",
|
||||
"get_network_params",
|
||||
"normalize_pandas_for_lightgbm",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,256 @@
|
||||
import tempfile
|
||||
from abc import abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union
|
||||
|
||||
from lightgbm.basic import Booster
|
||||
from lightgbm.callback import CallbackEnv
|
||||
|
||||
import ray.train
|
||||
from ray.train import Checkpoint
|
||||
from ray.tune.utils import flatten_dict
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
def normalize_pandas_for_lightgbm(df: "pd.DataFrame") -> "pd.DataFrame":
|
||||
"""Map Arrow-backed pandas dtypes to NumPy-nullable equivalents.
|
||||
|
||||
LightGBM's pandas input validation rejects Arrow-backed dtypes like
|
||||
``int64[pyarrow]``. Since Ray Data 2.56, ``Dataset.to_pandas()`` preserves
|
||||
Arrow-backed dtypes when the source was Arrow, so callers passing the
|
||||
resulting frame to ``lightgbm.Dataset`` must normalize first.
|
||||
|
||||
This helper is a faster alternative to
|
||||
``df.convert_dtypes(dtype_backend="numpy_nullable")``:
|
||||
|
||||
- It maps dtypes mechanically rather than scanning every value.
|
||||
- It only touches ``pd.ArrowDtype`` columns. NumPy-backed columns (e.g.
|
||||
from ``ray.data.from_pandas`` shards) keep their original buffers.
|
||||
|
||||
Only numeric and boolean Arrow dtypes are remapped. Other Arrow dtypes
|
||||
(string, decimal, timestamp) are left as-is; LightGBM doesn't accept them
|
||||
as features anyway.
|
||||
|
||||
Args:
|
||||
df: The pandas DataFrame to normalize.
|
||||
|
||||
Returns:
|
||||
A DataFrame with Arrow-backed numeric/boolean columns replaced by
|
||||
NumPy-nullable equivalents. Other columns are returned unchanged.
|
||||
"""
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
|
||||
dtype_mapping = {}
|
||||
for column, dtype in df.dtypes.items():
|
||||
if not isinstance(dtype, pd.ArrowDtype):
|
||||
continue
|
||||
arrow_dtype = dtype.pyarrow_dtype
|
||||
if pa.types.is_signed_integer(arrow_dtype):
|
||||
dtype_mapping[column] = f"Int{arrow_dtype.bit_width}"
|
||||
elif pa.types.is_unsigned_integer(arrow_dtype):
|
||||
dtype_mapping[column] = f"UInt{arrow_dtype.bit_width}"
|
||||
elif pa.types.is_floating(arrow_dtype):
|
||||
dtype_mapping[column] = f"Float{arrow_dtype.bit_width}"
|
||||
elif pa.types.is_boolean(arrow_dtype):
|
||||
dtype_mapping[column] = "boolean"
|
||||
if dtype_mapping:
|
||||
df = df.astype(dtype_mapping, copy=False)
|
||||
return df
|
||||
|
||||
|
||||
class RayReportCallback:
|
||||
CHECKPOINT_NAME = "model.txt"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metrics: Optional[Union[str, List[str], Dict[str, str]]] = None,
|
||||
filename: str = CHECKPOINT_NAME,
|
||||
frequency: int = 0,
|
||||
checkpoint_at_end: bool = True,
|
||||
results_postprocessing_fn: Optional[
|
||||
Callable[[Dict[str, Union[float, List[float]]]], Dict[str, float]]
|
||||
] = None,
|
||||
):
|
||||
if isinstance(metrics, str):
|
||||
metrics = [metrics]
|
||||
self._metrics = metrics
|
||||
self._filename = filename
|
||||
self._frequency = frequency
|
||||
self._checkpoint_at_end = checkpoint_at_end
|
||||
self._results_postprocessing_fn = results_postprocessing_fn
|
||||
|
||||
@classmethod
|
||||
def get_model(
|
||||
cls, checkpoint: Checkpoint, filename: str = CHECKPOINT_NAME
|
||||
) -> Booster:
|
||||
"""Retrieve the model stored in a checkpoint reported by this callback.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint object returned by a training run.
|
||||
The checkpoint should be saved by an instance of this callback.
|
||||
filename: The filename to load the model from, which should match
|
||||
the filename used when creating the callback.
|
||||
Returns:
|
||||
The model loaded from the checkpoint.
|
||||
"""
|
||||
with checkpoint.as_directory() as checkpoint_path:
|
||||
return Booster(model_file=Path(checkpoint_path, filename).as_posix())
|
||||
|
||||
def _get_report_dict(self, evals_log: Dict[str, Dict[str, list]]) -> dict:
|
||||
result_dict = flatten_dict(evals_log, delimiter="-")
|
||||
if not self._metrics:
|
||||
report_dict = result_dict
|
||||
else:
|
||||
report_dict = {}
|
||||
for key in self._metrics:
|
||||
if isinstance(self._metrics, dict):
|
||||
metric = self._metrics[key]
|
||||
else:
|
||||
metric = key
|
||||
report_dict[key] = result_dict[metric]
|
||||
if self._results_postprocessing_fn:
|
||||
report_dict = self._results_postprocessing_fn(report_dict)
|
||||
return report_dict
|
||||
|
||||
def _get_eval_result(self, env: CallbackEnv) -> dict:
|
||||
eval_result = {}
|
||||
for entry in env.evaluation_result_list:
|
||||
data_name, eval_name, result = entry[0:3]
|
||||
if len(entry) > 4:
|
||||
stdv = entry[4]
|
||||
suffix = "-mean"
|
||||
else:
|
||||
stdv = None
|
||||
suffix = ""
|
||||
if data_name not in eval_result:
|
||||
eval_result[data_name] = {}
|
||||
eval_result[data_name][eval_name + suffix] = result
|
||||
if stdv is not None:
|
||||
eval_result[data_name][eval_name + "-stdv"] = stdv
|
||||
return eval_result
|
||||
|
||||
@abstractmethod
|
||||
def _get_checkpoint(self, model: Booster) -> Optional[Checkpoint]:
|
||||
"""Get checkpoint from model.
|
||||
|
||||
This method needs to be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _save_and_report_checkpoint(self, report_dict: Dict, model: Booster):
|
||||
"""Save checkpoint and report metrics corresonding to this checkpoint.
|
||||
|
||||
This method needs to be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _report_metrics(self, report_dict: Dict):
|
||||
"""Report Metrics.
|
||||
|
||||
This method needs to be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __call__(self, env: CallbackEnv) -> None:
|
||||
eval_result = self._get_eval_result(env)
|
||||
report_dict = self._get_report_dict(eval_result)
|
||||
|
||||
# Ex: if frequency=2, checkpoint_at_end=True and num_boost_rounds=11,
|
||||
# you will checkpoint at iterations 1, 3, 5, ..., 9, and 10 (checkpoint_at_end)
|
||||
# (iterations count from 0)
|
||||
on_last_iter = env.iteration == env.end_iteration - 1
|
||||
should_checkpoint_at_end = on_last_iter and self._checkpoint_at_end
|
||||
should_checkpoint_with_frequency = (
|
||||
self._frequency != 0 and (env.iteration + 1) % self._frequency == 0
|
||||
)
|
||||
should_checkpoint = should_checkpoint_at_end or should_checkpoint_with_frequency
|
||||
|
||||
if should_checkpoint:
|
||||
self._save_and_report_checkpoint(report_dict, env.model)
|
||||
else:
|
||||
self._report_metrics(report_dict)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayTrainReportCallback(RayReportCallback):
|
||||
"""Creates a callback that reports metrics and checkpoints model.
|
||||
|
||||
Args:
|
||||
metrics: Metrics to report. If this is a list,
|
||||
each item should be a metric key reported by LightGBM,
|
||||
and it will be reported to Ray Train/Tune under the same name.
|
||||
This can also be a dict of {<key-to-report>: <lightgbm-metric-key>},
|
||||
which can be used to rename LightGBM default metrics.
|
||||
filename: Customize the saved checkpoint file type by passing
|
||||
a filename. Defaults to "model.txt".
|
||||
frequency: How often to save checkpoints, in terms of iterations.
|
||||
Defaults to 0 (no checkpoints are saved during training).
|
||||
checkpoint_at_end: Whether or not to save a checkpoint at the end of training.
|
||||
results_postprocessing_fn: An optional Callable that takes in
|
||||
the metrics dict that will be reported (after it has been flattened)
|
||||
and returns a modified dict.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Reporting checkpoints and metrics to Ray Tune when running many
|
||||
independent LightGBM trials (without data parallelism within a trial).
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import lightgbm
|
||||
|
||||
from ray.train.lightgbm import RayTrainReportCallback
|
||||
|
||||
config = {
|
||||
# ...
|
||||
"metric": ["binary_logloss", "binary_error"],
|
||||
}
|
||||
|
||||
# Report only log loss to Tune after each validation epoch.
|
||||
bst = lightgbm.train(
|
||||
...,
|
||||
callbacks=[
|
||||
RayTrainReportCallback(
|
||||
metrics={"loss": "eval-binary_logloss"}, frequency=1
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
Loading a model from a checkpoint reported by this callback.
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
from ray.train.lightgbm import RayTrainReportCallback
|
||||
|
||||
# Get a `Checkpoint` object that is saved by the callback during training.
|
||||
result = trainer.fit()
|
||||
booster = RayTrainReportCallback.get_model(result.checkpoint)
|
||||
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def _get_checkpoint(self, model: Booster) -> Optional[Checkpoint]:
|
||||
if ray.train.get_context().get_world_rank() in (0, None):
|
||||
with tempfile.TemporaryDirectory() as temp_checkpoint_dir:
|
||||
model.save_model(Path(temp_checkpoint_dir, self._filename).as_posix())
|
||||
yield Checkpoint.from_directory(temp_checkpoint_dir)
|
||||
else:
|
||||
yield None
|
||||
|
||||
def _save_and_report_checkpoint(self, report_dict: Dict, model: Booster):
|
||||
with self._get_checkpoint(model=model) as checkpoint:
|
||||
ray.train.report(report_dict, checkpoint=checkpoint)
|
||||
|
||||
def _report_metrics(self, report_dict: Dict):
|
||||
ray.train.report(report_dict)
|
||||
@@ -0,0 +1,98 @@
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import ray
|
||||
from ray._common.network_utils import build_address
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train._internal.utils import get_address_and_port
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.v2._internal.util import TrainingFramework
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Global LightGBM distributed network configuration for each worker process.
|
||||
_lightgbm_network_params: Optional[Dict[str, Any]] = None
|
||||
_lightgbm_network_params_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_network_params() -> Dict[str, Any]:
|
||||
"""Returns the network parameters to enable LightGBM distributed training."""
|
||||
global _lightgbm_network_params
|
||||
|
||||
with _lightgbm_network_params_lock:
|
||||
if not _lightgbm_network_params:
|
||||
logger.warning(
|
||||
"`ray.train.lightgbm.get_network_params` was called outside "
|
||||
"the context of a `ray.train.lightgbm.LightGBMTrainer`. "
|
||||
"The current process has no knowledge of the distributed training "
|
||||
"worker group, so this method will return an empty dict. "
|
||||
"Please call this within the training loop of a "
|
||||
"`ray.train.lightgbm.LightGBMTrainer`. "
|
||||
"If you are in fact calling this within a `LightGBMTrainer`, "
|
||||
"this is unexpected: please file a bug report to the Ray Team."
|
||||
)
|
||||
return {}
|
||||
|
||||
return _lightgbm_network_params.copy()
|
||||
|
||||
|
||||
def _set_network_params(
|
||||
num_machines: int,
|
||||
local_listen_port: int,
|
||||
machines: str,
|
||||
):
|
||||
global _lightgbm_network_params
|
||||
|
||||
with _lightgbm_network_params_lock:
|
||||
assert (
|
||||
_lightgbm_network_params is None
|
||||
), "LightGBM network params are already initialized."
|
||||
_lightgbm_network_params = dict(
|
||||
num_machines=num_machines,
|
||||
local_listen_port=local_listen_port,
|
||||
machines=machines,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightGBMConfig(BackendConfig):
|
||||
"""Configuration for LightGBM distributed data-parallel training setup.
|
||||
|
||||
See the LightGBM docs for more information on the "network parameters"
|
||||
that Ray Train sets up for you:
|
||||
https://lightgbm.readthedocs.io/en/latest/Parameters.html#network-parameters
|
||||
"""
|
||||
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return _LightGBMBackend
|
||||
|
||||
@property
|
||||
def framework(self):
|
||||
return TrainingFramework.LIGHTGBM
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
class _LightGBMBackend(Backend):
|
||||
def on_training_start(
|
||||
self, worker_group: BaseWorkerGroup, backend_config: LightGBMConfig
|
||||
):
|
||||
node_ips_and_ports = worker_group.execute(get_address_and_port)
|
||||
ports = [port for _, port in node_ips_and_ports]
|
||||
machines = ",".join(
|
||||
[build_address(node_ip, port) for node_ip, port in node_ips_and_ports]
|
||||
)
|
||||
num_machines = len(worker_group)
|
||||
ray.get(
|
||||
[
|
||||
worker_group.execute_single_async(
|
||||
rank, _set_network_params, num_machines, ports[rank], machines
|
||||
)
|
||||
for rank in range(len(worker_group))
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import lightgbm
|
||||
|
||||
from ray.train._internal.framework_checkpoint import FrameworkCheckpoint
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class LightGBMCheckpoint(FrameworkCheckpoint):
|
||||
"""A :py:class:`~ray.train.Checkpoint` with LightGBM-specific functionality."""
|
||||
|
||||
MODEL_FILENAME = "model.txt"
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
cls,
|
||||
booster: lightgbm.Booster,
|
||||
*,
|
||||
preprocessor: Optional["Preprocessor"] = None,
|
||||
path: Optional[str] = None,
|
||||
) -> "LightGBMCheckpoint":
|
||||
"""Create a :py:class:`~ray.train.Checkpoint` that stores a LightGBM model.
|
||||
|
||||
Args:
|
||||
booster: The LightGBM model to store in the checkpoint.
|
||||
preprocessor: A fitted preprocessor to be applied before inference.
|
||||
path: The path to the directory where the checkpoint file will be saved.
|
||||
This should start as an empty directory, since the *entire*
|
||||
directory will be treated as the checkpoint when reported.
|
||||
By default, a temporary directory will be created.
|
||||
|
||||
Returns:
|
||||
An :py:class:`LightGBMCheckpoint` containing the specified ``Estimator``.
|
||||
|
||||
Examples:
|
||||
.. testcode::
|
||||
|
||||
import lightgbm
|
||||
import numpy as np
|
||||
from ray.train.lightgbm import LightGBMCheckpoint
|
||||
|
||||
train_X = np.array([[1, 2], [3, 4]])
|
||||
train_y = np.array([0, 1])
|
||||
|
||||
model = lightgbm.LGBMClassifier().fit(train_X, train_y)
|
||||
checkpoint = LightGBMCheckpoint.from_model(model.booster_)
|
||||
"""
|
||||
checkpoint_path = Path(path or tempfile.mkdtemp())
|
||||
|
||||
if not checkpoint_path.is_dir():
|
||||
raise ValueError(f"`path` must be a directory, but got: {checkpoint_path}")
|
||||
|
||||
booster.save_model(checkpoint_path.joinpath(cls.MODEL_FILENAME).as_posix())
|
||||
|
||||
checkpoint = cls.from_directory(checkpoint_path.as_posix())
|
||||
if preprocessor:
|
||||
checkpoint.set_preprocessor(preprocessor)
|
||||
|
||||
return checkpoint
|
||||
|
||||
def get_model(self) -> lightgbm.Booster:
|
||||
"""Retrieve the LightGBM model stored in this checkpoint."""
|
||||
with self.as_directory() as checkpoint_path:
|
||||
return lightgbm.Booster(
|
||||
model_file=Path(checkpoint_path, self.MODEL_FILENAME).as_posix()
|
||||
)
|
||||
@@ -0,0 +1,326 @@
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
|
||||
import lightgbm
|
||||
|
||||
import ray
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
from ray.train.lightgbm._lightgbm_utils import (
|
||||
RayTrainReportCallback,
|
||||
normalize_pandas_for_lightgbm,
|
||||
)
|
||||
from ray.train.lightgbm.config import LightGBMConfig
|
||||
from ray.train.lightgbm.v2 import LightGBMTrainer as SimpleLightGBMTrainer
|
||||
from ray.train.trainer import GenDataset
|
||||
from ray.train.utils import _log_deprecation_warning
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LEGACY_LIGHTGBM_TRAINER_DEPRECATION_MESSAGE = (
|
||||
"Passing in `lightgbm.train` kwargs such as `params`, `num_boost_round`, "
|
||||
"`label_column`, etc. to `LightGBMTrainer` is deprecated "
|
||||
"in favor of the new API which accepts a `train_loop_per_worker` argument, "
|
||||
"similar to the other DataParallelTrainer APIs (ex: TorchTrainer). "
|
||||
"See this issue for more context: "
|
||||
"https://github.com/ray-project/ray/issues/50042"
|
||||
)
|
||||
|
||||
|
||||
def _lightgbm_train_fn_per_worker(
|
||||
config: dict,
|
||||
label_column: str,
|
||||
num_boost_round: int,
|
||||
dataset_keys: set,
|
||||
lightgbm_train_kwargs: dict,
|
||||
):
|
||||
checkpoint = ray.train.get_checkpoint()
|
||||
starting_model = None
|
||||
remaining_iters = num_boost_round
|
||||
if checkpoint:
|
||||
starting_model = RayTrainReportCallback.get_model(checkpoint)
|
||||
starting_iter = starting_model.current_iteration()
|
||||
remaining_iters = num_boost_round - starting_iter
|
||||
logger.info(
|
||||
f"Model loaded from checkpoint will train for "
|
||||
f"additional {remaining_iters} iterations (trees) in order "
|
||||
"to achieve the target number of iterations "
|
||||
f"({num_boost_round=})."
|
||||
)
|
||||
|
||||
train_ds_iter = ray.train.get_dataset_shard(TRAIN_DATASET_KEY)
|
||||
train_df = normalize_pandas_for_lightgbm(train_ds_iter.materialize().to_pandas())
|
||||
|
||||
eval_ds_iters = {
|
||||
k: ray.train.get_dataset_shard(k)
|
||||
for k in dataset_keys
|
||||
if k != TRAIN_DATASET_KEY
|
||||
}
|
||||
eval_dfs = {
|
||||
k: normalize_pandas_for_lightgbm(d.materialize().to_pandas())
|
||||
for k, d in eval_ds_iters.items()
|
||||
}
|
||||
|
||||
train_X, train_y = train_df.drop(label_column, axis=1), train_df[label_column]
|
||||
train_set = lightgbm.Dataset(train_X, label=train_y)
|
||||
|
||||
# NOTE: Include the training dataset in the evaluation datasets.
|
||||
# This allows `train-*` metrics to be calculated and reported.
|
||||
valid_sets = [train_set]
|
||||
valid_names = [TRAIN_DATASET_KEY]
|
||||
|
||||
for eval_name, eval_df in eval_dfs.items():
|
||||
eval_X, eval_y = eval_df.drop(label_column, axis=1), eval_df[label_column]
|
||||
valid_sets.append(lightgbm.Dataset(eval_X, label=eval_y))
|
||||
valid_names.append(eval_name)
|
||||
|
||||
# Add network params of the worker group to enable distributed training.
|
||||
config.update(ray.train.lightgbm.get_network_params())
|
||||
config.setdefault("tree_learner", "data_parallel")
|
||||
config.setdefault("pre_partition", True)
|
||||
|
||||
lightgbm.train(
|
||||
params=config,
|
||||
train_set=train_set,
|
||||
num_boost_round=remaining_iters,
|
||||
valid_sets=valid_sets,
|
||||
valid_names=valid_names,
|
||||
init_model=starting_model,
|
||||
**lightgbm_train_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class LightGBMTrainer(SimpleLightGBMTrainer):
|
||||
"""A Trainer for distributed data-parallel LightGBM training.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import lightgbm
|
||||
|
||||
import ray.data
|
||||
import ray.train
|
||||
from ray.train.lightgbm import (
|
||||
LightGBMTrainer,
|
||||
RayTrainReportCallback,
|
||||
normalize_pandas_for_lightgbm,
|
||||
)
|
||||
|
||||
def train_fn_per_worker(config: dict):
|
||||
# (Optional) Add logic to resume training state from a checkpoint.
|
||||
# ray.train.get_checkpoint()
|
||||
|
||||
# 1. Get the dataset shard for the worker and convert to a `lightgbm.Dataset`
|
||||
train_ds_iter, eval_ds_iter = (
|
||||
ray.train.get_dataset_shard("train"),
|
||||
ray.train.get_dataset_shard("validation"),
|
||||
)
|
||||
train_ds, eval_ds = train_ds_iter.materialize(), eval_ds_iter.materialize()
|
||||
train_df = normalize_pandas_for_lightgbm(train_ds.to_pandas())
|
||||
eval_df = normalize_pandas_for_lightgbm(eval_ds.to_pandas())
|
||||
train_X, train_y = train_df.drop("y", axis=1), train_df["y"]
|
||||
eval_X, eval_y = eval_df.drop("y", axis=1), eval_df["y"]
|
||||
dtrain = lightgbm.Dataset(train_X, label=train_y)
|
||||
deval = lightgbm.Dataset(eval_X, label=eval_y)
|
||||
|
||||
params = {
|
||||
"objective": "regression",
|
||||
"metric": "l2",
|
||||
"learning_rate": 1e-4,
|
||||
"subsample": 0.5,
|
||||
"max_depth": 2,
|
||||
# Adding the line below is the only change needed
|
||||
# for your `lgb.train` call!
|
||||
**ray.train.lightgbm.get_network_params(),
|
||||
}
|
||||
|
||||
# 2. Do distributed data-parallel training.
|
||||
# Ray Train sets up the necessary coordinator processes and
|
||||
# environment variables for your workers to communicate with each other.
|
||||
bst = lightgbm.train(
|
||||
params,
|
||||
train_set=dtrain,
|
||||
valid_sets=[deval],
|
||||
valid_names=["validation"],
|
||||
num_boost_round=10,
|
||||
callbacks=[RayTrainReportCallback()],
|
||||
)
|
||||
|
||||
train_ds = ray.data.from_items([{"x": x, "y": x + 1} for x in range(32)])
|
||||
eval_ds = ray.data.from_items([{"x": x, "y": x + 1} for x in range(16)])
|
||||
trainer = LightGBMTrainer(
|
||||
train_fn_per_worker,
|
||||
datasets={"train": train_ds, "validation": eval_ds},
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=4),
|
||||
)
|
||||
result = trainer.fit()
|
||||
booster = RayTrainReportCallback.get_model(result.checkpoint)
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute on each worker.
|
||||
This function can either take in zero arguments or a single ``Dict``
|
||||
argument which is set by defining ``train_loop_config``.
|
||||
Within this function you can use any of the
|
||||
:ref:`Ray Train Loop utilities <train-loop-api>`.
|
||||
train_loop_config: A configuration ``Dict`` to pass in as an argument to
|
||||
``train_loop_per_worker``.
|
||||
This is typically used for specifying hyperparameters.
|
||||
lightgbm_config: The configuration for setting up the distributed lightgbm
|
||||
backend. Defaults to using the "rabit" backend.
|
||||
See :class:`~ray.train.lightgbm.LightGBMConfig` for more info.
|
||||
scaling_config: The configuration for how to scale data parallel training.
|
||||
``num_workers`` determines how many Python processes are used for training,
|
||||
and ``use_gpu`` determines whether or not each process should use GPUs.
|
||||
See :class:`~ray.train.ScalingConfig` for more info.
|
||||
run_config: The configuration for the execution of the training run.
|
||||
See :class:`~ray.train.RunConfig` for more info.
|
||||
datasets: The Ray Datasets to use for training and validation.
|
||||
dataset_config: The configuration for ingesting the input ``datasets``.
|
||||
By default, all the Ray Datasets are split equally across workers.
|
||||
See :class:`~ray.train.DataConfig` for more details.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
This checkpoint can be accessed from within ``train_loop_per_worker``
|
||||
by calling ``ray.train.get_checkpoint()``.
|
||||
metadata: Dict that should be made available via
|
||||
`ray.train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
label_column: [Deprecated] Name of the label column. A column with this name
|
||||
must be present in the training dataset.
|
||||
params: [Deprecated] LightGBM training parameters.
|
||||
Refer to `LightGBM documentation <https://lightgbm.readthedocs.io/>`_
|
||||
for a list of possible parameters.
|
||||
num_boost_round: [Deprecated] Target number of boosting iterations (trees in the model).
|
||||
Note that unlike in ``lightgbm.train``, this is the target number
|
||||
of trees, meaning that if you set ``num_boost_round=10`` and pass a model
|
||||
that has already been trained for 5 iterations, it will be trained for 5
|
||||
iterations more, instead of 10 more.
|
||||
**train_kwargs: [Deprecated] Additional kwargs passed to ``lightgbm.train()`` function.
|
||||
"""
|
||||
|
||||
_handles_checkpoint_freq = True
|
||||
_handles_checkpoint_at_end = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Optional[
|
||||
Union[Callable[[], None], Callable[[Dict], None]]
|
||||
] = None,
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
lightgbm_config: Optional[LightGBMConfig] = None,
|
||||
scaling_config: Optional[ray.train.ScalingConfig] = None,
|
||||
run_config: Optional[ray.train.RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
dataset_config: Optional[ray.train.DataConfig] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
# TODO: [Deprecated] Legacy LightGBMTrainer API
|
||||
label_column: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
num_boost_round: Optional[int] = None,
|
||||
**train_kwargs,
|
||||
):
|
||||
# TODO: [Deprecated] Legacy LightGBMTrainer API
|
||||
legacy_api = train_loop_per_worker is None
|
||||
if legacy_api:
|
||||
train_loop_per_worker = self._get_legacy_train_fn_per_worker(
|
||||
lightgbm_train_kwargs=train_kwargs,
|
||||
run_config=run_config,
|
||||
label_column=label_column,
|
||||
num_boost_round=num_boost_round,
|
||||
datasets=datasets,
|
||||
)
|
||||
train_loop_config = params or {}
|
||||
elif train_kwargs:
|
||||
_log_deprecation_warning(
|
||||
"Passing `lightgbm.train` kwargs to `LightGBMTrainer` is deprecated. "
|
||||
f"Got kwargs: {train_kwargs.keys()}\n"
|
||||
"In your training function, you can call `lightgbm.train(**kwargs)` "
|
||||
"with arbitrary arguments. "
|
||||
f"{LEGACY_LIGHTGBM_TRAINER_DEPRECATION_MESSAGE}"
|
||||
)
|
||||
|
||||
super(LightGBMTrainer, self).__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
lightgbm_config=lightgbm_config,
|
||||
scaling_config=scaling_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
dataset_config=dataset_config,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _get_legacy_train_fn_per_worker(
|
||||
self,
|
||||
lightgbm_train_kwargs: Dict,
|
||||
run_config: Optional[ray.train.RunConfig],
|
||||
datasets: Optional[Dict[str, GenDataset]],
|
||||
label_column: Optional[str],
|
||||
num_boost_round: Optional[int],
|
||||
) -> Callable[[Dict], None]:
|
||||
"""Get the training function for the legacy LightGBMTrainer API."""
|
||||
|
||||
datasets = datasets or {}
|
||||
if not datasets.get(TRAIN_DATASET_KEY):
|
||||
raise ValueError(
|
||||
"`datasets` must be provided for the LightGBMTrainer API "
|
||||
"if `train_loop_per_worker` is not provided. "
|
||||
"This dict must contain the training dataset under the "
|
||||
f"key: '{TRAIN_DATASET_KEY}'. "
|
||||
f"Got keys: {list(datasets.keys())}"
|
||||
)
|
||||
if not label_column:
|
||||
raise ValueError(
|
||||
"`label_column` must be provided for the LightGBMTrainer API "
|
||||
"if `train_loop_per_worker` is not provided. "
|
||||
"This is the column name of the label in the dataset."
|
||||
)
|
||||
|
||||
num_boost_round = num_boost_round or 10
|
||||
|
||||
_log_deprecation_warning(LEGACY_LIGHTGBM_TRAINER_DEPRECATION_MESSAGE)
|
||||
|
||||
# Initialize a default Ray Train metrics/checkpoint reporting callback if needed
|
||||
callbacks = lightgbm_train_kwargs.get("callbacks", [])
|
||||
user_supplied_callback = any(
|
||||
isinstance(callback, RayTrainReportCallback) for callback in callbacks
|
||||
)
|
||||
callback_kwargs = {}
|
||||
if run_config:
|
||||
checkpoint_frequency = run_config.checkpoint_config.checkpoint_frequency
|
||||
checkpoint_at_end = run_config.checkpoint_config.checkpoint_at_end
|
||||
|
||||
callback_kwargs["frequency"] = checkpoint_frequency
|
||||
# Default `checkpoint_at_end=True` unless the user explicitly sets it.
|
||||
callback_kwargs["checkpoint_at_end"] = (
|
||||
checkpoint_at_end if checkpoint_at_end is not None else True
|
||||
)
|
||||
|
||||
if not user_supplied_callback:
|
||||
callbacks.append(RayTrainReportCallback(**callback_kwargs))
|
||||
lightgbm_train_kwargs["callbacks"] = callbacks
|
||||
|
||||
train_fn_per_worker = partial(
|
||||
_lightgbm_train_fn_per_worker,
|
||||
label_column=label_column,
|
||||
num_boost_round=num_boost_round,
|
||||
dataset_keys=set(datasets),
|
||||
lightgbm_train_kwargs=lightgbm_train_kwargs,
|
||||
)
|
||||
return train_fn_per_worker
|
||||
|
||||
@classmethod
|
||||
def get_model(
|
||||
cls,
|
||||
checkpoint: Checkpoint,
|
||||
) -> lightgbm.Booster:
|
||||
"""Retrieve the LightGBM model stored in this checkpoint."""
|
||||
return RayTrainReportCallback.get_model(checkpoint)
|
||||
@@ -0,0 +1,131 @@
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
|
||||
import ray.train
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.lightgbm.config import LightGBMConfig, get_network_params # noqa: F401
|
||||
from ray.train.trainer import GenDataset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LightGBMTrainer(DataParallelTrainer):
|
||||
"""A Trainer for distributed data-parallel LightGBM training.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
import lightgbm as lgb
|
||||
|
||||
import ray.data
|
||||
import ray.train
|
||||
from ray.train.lightgbm import (
|
||||
LightGBMTrainer,
|
||||
RayTrainReportCallback,
|
||||
normalize_pandas_for_lightgbm,
|
||||
)
|
||||
|
||||
|
||||
def train_fn_per_worker(config: dict):
|
||||
# (Optional) Add logic to resume training state from a checkpoint.
|
||||
# ray.train.get_checkpoint()
|
||||
|
||||
# 1. Get the dataset shard for the worker and convert to a `lgb.Dataset`
|
||||
train_ds_iter, eval_ds_iter = (
|
||||
ray.train.get_dataset_shard("train"),
|
||||
ray.train.get_dataset_shard("validation"),
|
||||
)
|
||||
train_ds, eval_ds = train_ds_iter.materialize(), eval_ds_iter.materialize()
|
||||
train_df = normalize_pandas_for_lightgbm(train_ds.to_pandas())
|
||||
eval_df = normalize_pandas_for_lightgbm(eval_ds.to_pandas())
|
||||
train_X, train_y = train_df.drop("y", axis=1), train_df["y"]
|
||||
eval_X, eval_y = eval_df.drop("y", axis=1), eval_df["y"]
|
||||
|
||||
train_set = lgb.Dataset(train_X, label=train_y)
|
||||
eval_set = lgb.Dataset(eval_X, label=eval_y)
|
||||
|
||||
# 2. Run distributed data-parallel training.
|
||||
# `get_network_params` sets up the necessary configurations for LightGBM
|
||||
# to set up the data parallel training worker group on your Ray cluster.
|
||||
params = {
|
||||
"objective": "regression",
|
||||
# Adding the line below is the only change needed
|
||||
# for your `lgb.train` call!
|
||||
**ray.train.lightgbm.get_network_params(),
|
||||
}
|
||||
lgb.train(
|
||||
params,
|
||||
train_set,
|
||||
valid_sets=[eval_set],
|
||||
valid_names=["eval"],
|
||||
callbacks=[RayTrainReportCallback()],
|
||||
)
|
||||
|
||||
train_ds = ray.data.from_items([{"x": x, "y": x + 1} for x in range(32)])
|
||||
eval_ds = ray.data.from_items(
|
||||
[{"x": x, "y": x + 1} for x in range(32, 32 + 16)]
|
||||
)
|
||||
trainer = LightGBMTrainer(
|
||||
train_fn_per_worker,
|
||||
datasets={"train": train_ds, "validation": eval_ds},
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=4),
|
||||
)
|
||||
result = trainer.fit()
|
||||
booster = RayTrainReportCallback.get_model(result.checkpoint)
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute on each worker.
|
||||
This function can either take in zero arguments or a single ``Dict``
|
||||
argument which is set by defining ``train_loop_config``.
|
||||
Within this function you can use any of the
|
||||
:ref:`Ray Train Loop utilities <train-loop-api>`.
|
||||
train_loop_config: A configuration ``Dict`` to pass in as an argument to
|
||||
``train_loop_per_worker``.
|
||||
This is typically used for specifying hyperparameters.
|
||||
lightgbm_config: The configuration for setting up the distributed lightgbm
|
||||
backend. See :class:`~ray.train.lightgbm.LightGBMConfig` for more info.
|
||||
scaling_config: The configuration for how to scale data parallel training.
|
||||
``num_workers`` determines how many Python processes are used for training,
|
||||
and ``use_gpu`` determines whether or not each process should use GPUs.
|
||||
See :class:`~ray.train.ScalingConfig` for more info.
|
||||
run_config: The configuration for the execution of the training run.
|
||||
See :class:`~ray.train.RunConfig` for more info.
|
||||
datasets: The Ray Datasets to use for training and validation.
|
||||
dataset_config: The configuration for ingesting the input ``datasets``.
|
||||
By default, all the Ray Dataset are split equally across workers.
|
||||
See :class:`~ray.train.DataConfig` for more details.
|
||||
metadata: Dict that should be made available via
|
||||
`ray.train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
This checkpoint can be accessed from within ``train_loop_per_worker``
|
||||
by calling ``ray.train.get_checkpoint()``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Union[Callable[[], None], Callable[[Dict], None]],
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
lightgbm_config: Optional[LightGBMConfig] = None,
|
||||
scaling_config: Optional[ray.train.ScalingConfig] = None,
|
||||
run_config: Optional[ray.train.RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
dataset_config: Optional[ray.train.DataConfig] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
super(LightGBMTrainer, self).__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
backend_config=lightgbm_config or LightGBMConfig(),
|
||||
scaling_config=scaling_config,
|
||||
dataset_config=dataset_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# isort: off
|
||||
try:
|
||||
import lightning # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import pytorch_lightning # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"PyTorch Lightning isn't installed. To install PyTorch Lightning, "
|
||||
"please run 'pip install lightning'"
|
||||
)
|
||||
# isort: on
|
||||
|
||||
from ray.train.lightning._lightning_utils import (
|
||||
RayDDPStrategy,
|
||||
RayDeepSpeedStrategy,
|
||||
RayFSDPStrategy,
|
||||
RayLightningEnvironment,
|
||||
RayTrainReportCallback,
|
||||
prepare_trainer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"prepare_trainer",
|
||||
"RayDDPStrategy",
|
||||
"RayFSDPStrategy",
|
||||
"RayDeepSpeedStrategy",
|
||||
"RayLightningEnvironment",
|
||||
"RayTrainReportCallback",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,353 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import torch
|
||||
from packaging.version import Version
|
||||
|
||||
import ray
|
||||
import ray.train
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
from ray.train import Checkpoint
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
from ray.util import PublicAPI
|
||||
|
||||
|
||||
def import_lightning(): # noqa: F402
|
||||
try:
|
||||
import lightning.pytorch as pl
|
||||
except ModuleNotFoundError:
|
||||
import pytorch_lightning as pl
|
||||
return pl
|
||||
|
||||
|
||||
pl = import_lightning()
|
||||
|
||||
if is_v2_enabled():
|
||||
from ray.train.v2.api.report_config import CheckpointUploadMode
|
||||
|
||||
_LIGHTNING_GREATER_EQUAL_2_0 = Version(pl.__version__) >= Version("2.0.0")
|
||||
_LIGHTNING_LESS_THAN_2_1 = Version(pl.__version__) < Version("2.1.0")
|
||||
_TORCH_GREATER_EQUAL_1_12 = Version(torch.__version__) >= Version("1.12.0")
|
||||
_TORCH_FSDP_AVAILABLE = _TORCH_GREATER_EQUAL_1_12 and torch.distributed.is_available()
|
||||
|
||||
try:
|
||||
from lightning.pytorch.plugins.environments import LightningEnvironment
|
||||
except ModuleNotFoundError:
|
||||
from pytorch_lightning.plugins.environments import LightningEnvironment
|
||||
|
||||
if _LIGHTNING_GREATER_EQUAL_2_0:
|
||||
FSDPStrategy = pl.strategies.FSDPStrategy
|
||||
else:
|
||||
FSDPStrategy = pl.strategies.DDPFullyShardedStrategy
|
||||
|
||||
if _TORCH_FSDP_AVAILABLE:
|
||||
from torch.distributed.fsdp import (
|
||||
FullStateDictConfig,
|
||||
FullyShardedDataParallel,
|
||||
StateDictType,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LIGHTNING_REPORT_STAGE_KEY = "_report_on"
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayDDPStrategy(pl.strategies.DDPStrategy):
|
||||
"""Subclass of DDPStrategy to ensure compatibility with Ray orchestration.
|
||||
|
||||
For a full list of initialization arguments, please refer to:
|
||||
https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.strategies.DDPStrategy.html
|
||||
|
||||
Note that `process_group_backend`, `timeout`, and `start_method` are disabled here,
|
||||
please specify these arguments in :class:`~ray.train.torch.TorchConfig` instead.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYDDPSTRATEGY, "1")
|
||||
|
||||
@property
|
||||
def root_device(self) -> torch.device:
|
||||
return ray.train.torch.get_device()
|
||||
|
||||
@property
|
||||
def distributed_sampler_kwargs(self) -> Dict[str, Any]:
|
||||
return dict(
|
||||
num_replicas=self.world_size,
|
||||
rank=self.global_rank,
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayFSDPStrategy(FSDPStrategy): # noqa: F821
|
||||
"""Subclass of FSDPStrategy to ensure compatibility with Ray orchestration.
|
||||
|
||||
For a full list of initialization arguments, please refer to:
|
||||
https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.strategies.FSDPStrategy.html
|
||||
|
||||
.. note::
|
||||
It is recommended to upgrade `lightning>=2.1` or above when using FSDP
|
||||
with Lightning, since Lightning starts to natively support `state_dict_type`,
|
||||
`sharding_strategy`, `auto_wrap_policy` and other FSDP configurations from 2.1.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYFSDPSTRATEGY, "1")
|
||||
|
||||
@property
|
||||
def root_device(self) -> torch.device:
|
||||
return ray.train.torch.get_device()
|
||||
|
||||
@property
|
||||
def distributed_sampler_kwargs(self) -> Dict[str, Any]:
|
||||
return dict(
|
||||
num_replicas=self.world_size,
|
||||
rank=self.global_rank,
|
||||
)
|
||||
|
||||
def lightning_module_state_dict(self) -> Dict[str, Any]:
|
||||
"""Gathers the full state dict to rank 0 on CPU.
|
||||
|
||||
FSDP checkpointing is broken in Lightning 2.0.x. This subclass patches the
|
||||
behavior to perform a full state dict checkpointing, gathering the checkpoint
|
||||
shards on rank 0 CPU. Upgrade to `lightning>=2.1` to do sharded state dict
|
||||
checkpointing.
|
||||
|
||||
See the note in the class docstring for more details.
|
||||
"""
|
||||
|
||||
assert self.model is not None, "Failed to get the state dict for a None model!"
|
||||
|
||||
if (
|
||||
_TORCH_FSDP_AVAILABLE
|
||||
and _LIGHTNING_GREATER_EQUAL_2_0
|
||||
and _LIGHTNING_LESS_THAN_2_1
|
||||
):
|
||||
with FullyShardedDataParallel.state_dict_type(
|
||||
module=self.model,
|
||||
state_dict_type=StateDictType.FULL_STATE_DICT,
|
||||
state_dict_config=FullStateDictConfig(
|
||||
offload_to_cpu=True, rank0_only=True
|
||||
),
|
||||
):
|
||||
state_dict = self.model.state_dict()
|
||||
|
||||
ckpt_state_dict = {}
|
||||
prefix_len = len("_forward_module.")
|
||||
for k, v in state_dict.items():
|
||||
if k.startswith("_forward_module."):
|
||||
non_prefixed_key = k[prefix_len:]
|
||||
ckpt_state_dict[non_prefixed_key] = v
|
||||
else:
|
||||
ckpt_state_dict[k] = v
|
||||
return ckpt_state_dict
|
||||
else:
|
||||
# Otherwise Lightning uses Fairscale FSDP, no need to unshard by ourself.
|
||||
return super().lightning_module_state_dict()
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayDeepSpeedStrategy(pl.strategies.DeepSpeedStrategy):
|
||||
"""Subclass of DeepSpeedStrategy to ensure compatibility with Ray orchestration.
|
||||
|
||||
For a full list of initialization arguments, please refer to:
|
||||
https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.strategies.DeepSpeedStrategy.html
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYDEEPSPEEDSTRATEGY, "1")
|
||||
|
||||
@property
|
||||
def root_device(self) -> torch.device:
|
||||
return ray.train.torch.get_device()
|
||||
|
||||
@property
|
||||
def distributed_sampler_kwargs(self) -> Dict[str, Any]:
|
||||
return dict(
|
||||
num_replicas=self.world_size,
|
||||
rank=self.global_rank,
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayLightningEnvironment(LightningEnvironment): # noqa: F821
|
||||
"""Setup Lightning DDP training environment for Ray cluster."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYLIGHTNINGENVIRONMENT, "1")
|
||||
|
||||
def world_size(self) -> int:
|
||||
return ray.train.get_context().get_world_size()
|
||||
|
||||
def global_rank(self) -> int:
|
||||
return ray.train.get_context().get_world_rank()
|
||||
|
||||
def local_rank(self) -> int:
|
||||
return ray.train.get_context().get_local_rank()
|
||||
|
||||
def node_rank(self) -> int:
|
||||
return ray.train.get_context().get_node_rank()
|
||||
|
||||
def set_world_size(self, size: int) -> None:
|
||||
# Disable it since `world_size()` directly returns data from Train context.
|
||||
pass
|
||||
|
||||
def set_global_rank(self, rank: int) -> None:
|
||||
# Disable it since `global_rank()` directly returns data from Train.
|
||||
pass
|
||||
|
||||
def teardown(self):
|
||||
pass
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def prepare_trainer(trainer: pl.Trainer) -> pl.Trainer:
|
||||
"""Prepare the PyTorch Lightning Trainer for distributed execution."""
|
||||
|
||||
# Check strategy class
|
||||
valid_strategy_class = [RayDDPStrategy, RayFSDPStrategy, RayDeepSpeedStrategy]
|
||||
|
||||
if not any(isinstance(trainer.strategy, cls) for cls in valid_strategy_class):
|
||||
raise RuntimeError(
|
||||
f"Invalid strategy class: {type(trainer.strategy)}. To use "
|
||||
"PyTorch Lightning with Ray, the strategy object should be one of "
|
||||
f"{[cls.__name__ for cls in valid_strategy_class]} class "
|
||||
"or its subclass."
|
||||
)
|
||||
|
||||
# Check cluster environment
|
||||
cluster_environment = getattr(trainer.strategy, "cluster_environment", None)
|
||||
if cluster_environment and not isinstance(
|
||||
cluster_environment, RayLightningEnvironment
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Invalid cluster environment plugin. The expected class is"
|
||||
"`ray.train.lightning.RayLightningEnvironment` "
|
||||
f"but got {type(cluster_environment)}!"
|
||||
)
|
||||
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_PREPARE_TRAINER, "1")
|
||||
return trainer
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class RayTrainReportCallback(pl.callbacks.Callback):
|
||||
"""A simple callback that reports checkpoints to Ray on train epoch end.
|
||||
|
||||
This callback is a subclass of `lightning.pytorch.callbacks.Callback
|
||||
<https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.callbacks.Callback.html#lightning.pytorch.callbacks.Callback>`_.
|
||||
|
||||
It fetches the latest `trainer.callback_metrics` and reports together with
|
||||
the checkpoint on each training epoch end.
|
||||
|
||||
Checkpoints will be saved in the following structure::
|
||||
|
||||
checkpoint_00000*/ Ray Train Checkpoint
|
||||
└─ checkpoint.ckpt PyTorch Lightning Checkpoint
|
||||
|
||||
You can also provide the following arguments to the callback:
|
||||
|
||||
- checkpoint_upload_mode: The manner in which to upload the checkpoint.
|
||||
See :ref:`Checkpoint upload modes <train-checkpoint-upload-modes>` for more details.
|
||||
- validation: Whether to asynchronously validate the checkpoint.
|
||||
See :ref:`Validating checkpoints asynchronously <train-validating-checkpoints>` for more details.
|
||||
|
||||
For customized reporting and checkpointing logic, implement your own
|
||||
`lightning.pytorch.callbacks.Callback` following this user
|
||||
guide: :ref:`Saving and Loading Checkpoints <train-dl-saving-checkpoints>`.
|
||||
"""
|
||||
|
||||
CHECKPOINT_NAME = "checkpoint.ckpt"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# v2-only arguments
|
||||
checkpoint_upload_mode=None,
|
||||
validation=False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.checkpoint_upload_mode = checkpoint_upload_mode
|
||||
self.validation = validation
|
||||
|
||||
if is_v2_enabled():
|
||||
if checkpoint_upload_mode is None:
|
||||
self.checkpoint_upload_mode = CheckpointUploadMode.SYNC
|
||||
else:
|
||||
if checkpoint_upload_mode is not None:
|
||||
raise ValueError(
|
||||
"`checkpoint_upload_mode` is only supported in Ray Train v2. "
|
||||
"To enable it, please set `RAY_TRAIN_V2_ENABLED=1`."
|
||||
)
|
||||
if validation:
|
||||
raise ValueError(
|
||||
"`validation` is only supported in Ray Train v2. "
|
||||
"To enable it, please set `RAY_TRAIN_V2_ENABLED=1`."
|
||||
)
|
||||
|
||||
job_id = ray.get_runtime_context().get_job_id()
|
||||
experiment_name = ray.train.get_context().get_experiment_name()
|
||||
|
||||
self.tmpdir_prefix = Path(
|
||||
tempfile.gettempdir(),
|
||||
f"lightning_checkpoints-job_id={job_id}-name={experiment_name}-world_rank={ray.train.get_context().get_world_rank()}",
|
||||
).as_posix()
|
||||
if os.path.isdir(self.tmpdir_prefix):
|
||||
shutil.rmtree(self.tmpdir_prefix)
|
||||
|
||||
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYTRAINREPORTCALLBACK, "1")
|
||||
|
||||
def on_train_epoch_end(self, trainer, pl_module) -> None:
|
||||
# Creates a checkpoint dir with fixed name
|
||||
tmpdir = Path(self.tmpdir_prefix, str(trainer.current_epoch)).as_posix()
|
||||
os.makedirs(tmpdir, exist_ok=True)
|
||||
|
||||
# Fetch metrics
|
||||
metrics = trainer.callback_metrics
|
||||
metrics = {k: v.item() for k, v in metrics.items()}
|
||||
|
||||
# (Optional) Add customized metrics
|
||||
metrics["epoch"] = trainer.current_epoch
|
||||
metrics["step"] = trainer.global_step
|
||||
|
||||
# Save checkpoint to local
|
||||
ckpt_path = Path(tmpdir, self.CHECKPOINT_NAME).as_posix()
|
||||
# TODO: with CheckpointUploadMode.ASYNC, this does cpu -> disk synchronously
|
||||
# and disk -> remote asynchronously. We can add a new CheckpointIO class to do
|
||||
# cpu -> remote asynchronously and a checkpoint_upload_fn to wait for it.
|
||||
trainer.save_checkpoint(ckpt_path, weights_only=False)
|
||||
|
||||
# Report to train session
|
||||
checkpoint = Checkpoint.from_directory(tmpdir)
|
||||
if is_v2_enabled():
|
||||
ray.train.report(
|
||||
metrics=metrics,
|
||||
checkpoint=checkpoint,
|
||||
checkpoint_upload_mode=self.checkpoint_upload_mode,
|
||||
validation=self.validation,
|
||||
)
|
||||
else:
|
||||
ray.train.report(metrics=metrics, checkpoint=checkpoint)
|
||||
|
||||
# Add a barrier to ensure all workers finished reporting here
|
||||
trainer.strategy.barrier()
|
||||
|
||||
# With CheckpointUploadMode.ASYNC, the upload may still be in progress
|
||||
# after report() returns. Let ray.train.report delete_local_checkpoint_after_upload
|
||||
# handle cleanup instead.
|
||||
if is_v2_enabled() and self.checkpoint_upload_mode is not None:
|
||||
# Check here because CheckpointUploadMode is only imported when is_v2_enabled() is True
|
||||
if (
|
||||
self.checkpoint_upload_mode.default_delete_local_checkpoint_after_upload()
|
||||
):
|
||||
return
|
||||
|
||||
shutil.rmtree(tmpdir)
|
||||
@@ -0,0 +1,29 @@
|
||||
## Ray Train Circular Import Linter
|
||||
|
||||
Ray Train functionality is overrided or "patched" by functionality from other directories. For instance, Ray Train is patched by functionality from Ray Train v2 when `RAY_TRAIN_V2_ENABLED=1`, making Ray Train dependent on Ray Train v2. In turn, the patching directory often imports functionality from the "base" Ray Train directory (`ray/python/ray/train`), resulting in a circular dependency. The Ray Train Circular Import Linter takes a patching directory, `patch_dir`, and detects circular imports between it and the base Ray Train directory- displaying violations to users via pre-commit.
|
||||
|
||||
### The Problem:
|
||||
|
||||
When there is a circular dependency present in the codebase, import errors are triggered by importing directly from the patching directory. For example, directly importing the v2 TensorflowTrainer with `from ray.train.v2.tensorflow.tensorflow_trainer import TensorflowTrainer` rather than relying on the conventional routing logic via `from ray.train.tensorflow import TensorflowTrainer` results in a `ImportError: cannot import name TensorflowTrainer from partially initialized ray.train.v2.tensorflow.tensorflow_trainer`. This happens in the case of user API misuse or during the deserialization of the train function on train worker setup. The following image depicts the error path of such erroneous imports.
|
||||
|
||||

|
||||
|
||||
### The Fix:
|
||||
|
||||
To make Ray Train resilient to such erroneous imports, this linter proactively detects circular imports and specifies how to fix it. The fix perscribed by the linter prevents import errors by importing the base Train packages early within in the patching directory. In the below example, the previously depicted circular import is resolved by the linter's suggested fix to import `ray.train.foo` early in `ray.train.v2.foo`.
|
||||
|
||||

|
||||
|
||||
The key observation is that the fix redirects the import path of `from ray.train.v2.foo import foo_v2` so that the base Train init file (e.g. `ray.train.foo.__init__.py`) runs before the patching file (e.g. `ray.train.v2.foo.py`), avoiding the error in the previous example.
|
||||
|
||||
### Linter Specification
|
||||
|
||||
The linter implements an `ast.NodeVisitor` to parse imports within the base Train directory and the patching directory to detect circular imports. The below example depicts two circular imports that would be detected by the linter originating from a `ray.train.common.__init__.py` file.
|
||||
|
||||

|
||||
|
||||
The linter parses all `__init__.py` files in the base Train directory and collects their imports. For each patching import (e.g. `from ray.train.v2.foo import foo_v2`), the linter will also collect the imports in the patching file (e.g. `ray.train.v2.foo.py`) and if any of these imports point back to the same base Train file (e.g. `ray.train.common.__init__.py`), a violation is detected.
|
||||
|
||||
However, notice from the diagram that the linter also detects violations in the case of reexports. If the base Train file points to a patching package file (e.g. `ray.train.v2.bar.__init__.py`), the linter will also collect the imports of the referenced implementation file (e.g. `ray.train.v2.bar.bar_impl.py`) to search for a violation.
|
||||
|
||||
That said, in both cases, if the linter finds that the base Train file is imported early in the patching package file (e.g. `ray.train.common` is imported in `ray.train.v2.foo.__init__.py`/`ray.train.v2.bar.__init__.py`), then the violation will be suppressed. Otherwise, this is the fix that will be reccommended by the linter.
|
||||
@@ -0,0 +1,393 @@
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
TRAIN_PACKAGES = set()
|
||||
|
||||
|
||||
def find_train_packages(base_train_dir: Path, patch_train_dir: Path) -> None:
|
||||
"""
|
||||
Find and initialize the global TRAIN_PACKAGES with all train package names from base_train_dir
|
||||
and patch_train_dir combined.
|
||||
"""
|
||||
global TRAIN_PACKAGES
|
||||
TRAIN_PACKAGES = set()
|
||||
|
||||
# Collect all packages under both base and patch train dirs
|
||||
package_files = list(base_train_dir.rglob("__init__.py")) + list(
|
||||
patch_train_dir.rglob("__init__.py")
|
||||
)
|
||||
base_dir = get_base_dir()
|
||||
for init_file in package_files:
|
||||
relative_path = init_file.relative_to(base_dir)
|
||||
dotted_module = str(relative_path.parent).replace("/", ".")
|
||||
TRAIN_PACKAGES.add(dotted_module)
|
||||
|
||||
|
||||
def is_train_package(module_str: str) -> bool:
|
||||
return module_str in TRAIN_PACKAGES
|
||||
|
||||
|
||||
def get_base_dir() -> Path:
|
||||
"""Return the filesystem path to the ray python directory."""
|
||||
current_file_path = Path(__file__).resolve()
|
||||
package_dir = current_file_path.parents[3]
|
||||
return package_dir
|
||||
|
||||
|
||||
def get_base_train_dir() -> Path:
|
||||
"""Return the filesystem path to the ray train directory."""
|
||||
return get_base_dir() / "ray/train"
|
||||
|
||||
|
||||
def does_overlap(main_module: str, module: str) -> bool:
|
||||
"""Checks if the init file of module is on the import path of main_module"""
|
||||
return main_module.startswith(f"{module}.") or main_module == module
|
||||
|
||||
|
||||
class Import:
|
||||
"""
|
||||
Represents an import statement.
|
||||
For example, 'from X import A, B' has module 'X' and names ['A', 'B'].
|
||||
Also supports 'import X'.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, module: str, names: List[str] = None, is_package: bool = False
|
||||
) -> None:
|
||||
self.is_package = is_package
|
||||
self.module = module
|
||||
self.names = names if names else []
|
||||
|
||||
|
||||
class ImportCollector(ast.NodeVisitor):
|
||||
"""
|
||||
An AST node visitor that collects all module-level imports from a Python source file.
|
||||
It traverses the AST and records module-level import statements (`import ...` and `from ... import ...`) that are not
|
||||
inside function or class definitions, and that are not guarded by `if TYPE_CHECKING` or `if typing.TYPE_CHECKING`
|
||||
blocks.
|
||||
"""
|
||||
|
||||
def __init__(self, module_name: str, is_package: bool) -> None:
|
||||
self._module_name = module_name
|
||||
self._is_package = is_package
|
||||
self.imports: Set[Import] = set()
|
||||
self.type_checking_imported = False
|
||||
|
||||
# --- private helpers ---
|
||||
|
||||
def _is_type_checking_test(self, expr: ast.AST) -> bool:
|
||||
"""Return True for `if TYPE_CHECKING` or `if typing.TYPE_CHECKING`."""
|
||||
|
||||
if (
|
||||
self.type_checking_imported
|
||||
and isinstance(expr, ast.Name)
|
||||
and expr.id == "TYPE_CHECKING"
|
||||
):
|
||||
return True
|
||||
elif (
|
||||
isinstance(expr, ast.Attribute)
|
||||
and isinstance(expr.value, ast.Name)
|
||||
and expr.value.id == "typing"
|
||||
and expr.attr == "TYPE_CHECKING"
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_package_parts(self) -> List[str]:
|
||||
parts = self._module_name.split(".")
|
||||
return parts if self._is_package else parts[:-1]
|
||||
|
||||
def _to_absolute_module(
|
||||
self, level: int, module_str: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Construct the absolute module string from a relative import."""
|
||||
# Absolute import
|
||||
if level == 0:
|
||||
return module_str
|
||||
|
||||
package_parts = self._get_package_parts()
|
||||
|
||||
# If the relative import is out of bounds
|
||||
if level - 1 > len(package_parts):
|
||||
return None
|
||||
|
||||
# Base parts based on the level
|
||||
base_module_parts = (
|
||||
package_parts if level == 1 else package_parts[: -(level - 1)]
|
||||
)
|
||||
|
||||
# Construct absolute module string
|
||||
abs_module_parts = (
|
||||
base_module_parts + module_str.split(".")
|
||||
if module_str
|
||||
else base_module_parts
|
||||
)
|
||||
return ".".join(abs_module_parts)
|
||||
|
||||
# --- parsing functions ---
|
||||
|
||||
def visit_If(self, node: ast.If) -> None:
|
||||
# If the test is not TYPE_CHECKING, visit statement body
|
||||
if not self._is_type_checking_test(node.test):
|
||||
for stmt in node.body:
|
||||
self.visit(stmt)
|
||||
|
||||
# Also visit conditional branches
|
||||
for stmt in node.orelse:
|
||||
self.visit(stmt)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
if alias.name:
|
||||
self.imports.add(
|
||||
Import(module=alias.name, is_package=is_train_package(alias.name))
|
||||
)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
import_str = self._to_absolute_module(node.level or 0, node.module)
|
||||
if not import_str:
|
||||
return
|
||||
|
||||
names = [alias.name for alias in node.names]
|
||||
self.imports.add(
|
||||
Import(
|
||||
module=import_str, is_package=is_train_package(import_str), names=names
|
||||
)
|
||||
)
|
||||
if "TYPE_CHECKING" in names and import_str == "typing":
|
||||
self.type_checking_imported = True
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
# Skip function contents
|
||||
return
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
||||
# Skip function contents
|
||||
return
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
# Skip class contents
|
||||
return
|
||||
|
||||
|
||||
def collect_imports(
|
||||
module_name: str, is_package: bool, source_text: str
|
||||
) -> Set[Import]:
|
||||
try:
|
||||
tree = ast.parse(source_text)
|
||||
except SyntaxError:
|
||||
print(f"Warning: Failed to parse {module_name} for circular imports")
|
||||
return set()
|
||||
collector = ImportCollector(module_name, is_package)
|
||||
collector.visit(tree)
|
||||
return collector.imports
|
||||
|
||||
|
||||
def to_module_name_and_is_package(py_file: Path) -> Tuple[str, bool]:
|
||||
"""
|
||||
Convert a Python file path to its corresponding module name and determine if it is a package.
|
||||
|
||||
Args:
|
||||
py_file: The path to the Python file.
|
||||
|
||||
Returns:
|
||||
Tuple[str, bool]: A tuple containing the module name as a string and a boolean indicating
|
||||
whether the module is a package (True if it is an __init__.py file).
|
||||
"""
|
||||
file_path = py_file.relative_to(get_base_dir())
|
||||
module_path = file_path.with_suffix("")
|
||||
module_parts = module_path.parts
|
||||
is_package = module_parts[-1] == "__init__"
|
||||
if is_package:
|
||||
module_parts = module_parts[:-1]
|
||||
module_str = ".".join(module_parts)
|
||||
return module_str, is_package
|
||||
|
||||
|
||||
def get_file_module_imports(
|
||||
files: List[Path], module_match_string: Optional[str] = None
|
||||
) -> Dict[str, List[Import]]:
|
||||
"""
|
||||
Collect and return the module-level imports for a list of Python files.
|
||||
|
||||
Args:
|
||||
files: A list of Path objects representing Python files to analyze.
|
||||
module_match_string: An optional string to filter imports. Only imports
|
||||
containing this string will be included in the result.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping module names to a list of their import statements.
|
||||
The module names are derived from the file paths, and the import statements
|
||||
are filtered based on the optional module_match_string.
|
||||
"""
|
||||
module_imports: Dict[str, List[Import]] = {}
|
||||
|
||||
# Collect the imports for each python file
|
||||
for py_file in files:
|
||||
try:
|
||||
module_name, is_package = to_module_name_and_is_package(py_file)
|
||||
src = py_file.read_text(encoding="utf-8", errors="ignore")
|
||||
imports = collect_imports(module_name, is_package, src)
|
||||
module_imports[module_name] = [
|
||||
stmt
|
||||
for stmt in imports
|
||||
if module_match_string is None or module_match_string in stmt.module
|
||||
]
|
||||
except Exception:
|
||||
continue
|
||||
return module_imports
|
||||
|
||||
|
||||
def convert_to_file_paths(imports: List[Import]) -> List[Path]:
|
||||
"""
|
||||
Convert a list of import strings to a list of file paths.
|
||||
|
||||
Args:
|
||||
imports: A list of Import objects
|
||||
|
||||
Returns:
|
||||
A list of file paths.
|
||||
"""
|
||||
base_dir = get_base_dir()
|
||||
file_paths = []
|
||||
for imp in imports:
|
||||
if imp.is_package:
|
||||
relative_path = imp.module.replace(".", "/") + "/__init__.py"
|
||||
else:
|
||||
relative_path = imp.module.replace(".", "/") + ".py"
|
||||
file_paths.append(base_dir / relative_path)
|
||||
return file_paths
|
||||
|
||||
|
||||
def expand_to_include_reexports(import_map: Dict[str, List[Import]]) -> None:
|
||||
"""
|
||||
Expands the set of imports for a given import map to include the modules resulting from reexports.
|
||||
So if in the base train module, there is "from x import a, b" and x is a package, then this function
|
||||
will explore the __init__.py of x and include the modules a and b were reexported from in the import map.
|
||||
"""
|
||||
for module, base_imports in import_map.items():
|
||||
# Get only the package imports
|
||||
packages = [imp for imp in base_imports if imp.is_package]
|
||||
package_files = convert_to_file_paths(packages)
|
||||
reexports = get_file_module_imports(package_files)
|
||||
|
||||
agg_reexports = []
|
||||
# Filter patch init file imports to those that only contain the right names
|
||||
for base_import in base_imports:
|
||||
if base_import.module in reexports:
|
||||
import_list = reexports[base_import.module]
|
||||
target_reexports = [
|
||||
imp
|
||||
for imp in import_list
|
||||
if set(imp.names) & set(base_import.names)
|
||||
]
|
||||
agg_reexports.extend(target_reexports)
|
||||
|
||||
# Expand modules to include reexported modules
|
||||
import_map[module].extend(agg_reexports)
|
||||
|
||||
|
||||
def check_violations(
|
||||
base_train_patching_imports: Dict[str, List[Import]], patch_dir: Path
|
||||
) -> List[str]:
|
||||
"""
|
||||
Check for circular import violations between base and patch train modules.
|
||||
|
||||
Args:
|
||||
base_train_patching_imports: A dictionary mapping base train module names to their imports.
|
||||
patch_dir: The directory path containing patch train modules.
|
||||
|
||||
Returns:
|
||||
A list of strings describing any circular import violations found.
|
||||
"""
|
||||
violations: List[str] = []
|
||||
|
||||
# Get the imports from the patch train init files
|
||||
patch_train_init_files = list(patch_dir.rglob("__init__.py"))
|
||||
patch_train_init_imports = get_file_module_imports(
|
||||
patch_train_init_files, module_match_string="ray.train"
|
||||
)
|
||||
|
||||
# Expand the imports to include reexports
|
||||
expand_to_include_reexports(base_train_patching_imports)
|
||||
|
||||
# Process each patch train init module for violations
|
||||
for base_train_init_module, imports in base_train_patching_imports.items():
|
||||
|
||||
# Get the imports from the patch train files
|
||||
patch_train_files = convert_to_file_paths(imports)
|
||||
patch_train_file_imports = get_file_module_imports(
|
||||
patch_train_files, module_match_string="ray.train"
|
||||
)
|
||||
|
||||
for patch_module, imports in patch_train_file_imports.items():
|
||||
# Skip if the base train init module is in the import path of the patch module
|
||||
if does_overlap(patch_module, base_train_init_module):
|
||||
continue
|
||||
|
||||
# Skip if the patch train module init file imports the base train init module
|
||||
patch_init_module = (
|
||||
".".join(patch_module.split(".")[:-1])
|
||||
if not is_train_package(patch_module)
|
||||
else patch_module
|
||||
)
|
||||
patch_init_imports = patch_train_init_imports.get(patch_init_module, [])
|
||||
if any(
|
||||
does_overlap(imp.module, base_train_init_module)
|
||||
for imp in patch_init_imports
|
||||
):
|
||||
continue
|
||||
|
||||
for patch_import in imports:
|
||||
# If any of those v1 imports go through the init file, then it is a violation
|
||||
if does_overlap(patch_import.module, base_train_init_module):
|
||||
violations.append(
|
||||
f"circular-import-train: Circular import between {base_train_init_module} (importing {patch_module}) and {patch_module} (importing {patch_import.module}). Resolve by importing {base_train_init_module} in the __init__.py of {patch_init_module}."
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--patch_dir",
|
||||
default="ray/train/v2",
|
||||
help="Path to the directory containing patching contents",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get train directory paths
|
||||
base_dir = get_base_dir()
|
||||
base_train_dir = get_base_train_dir()
|
||||
patch_train_dir = base_dir / Path(args.patch_dir)
|
||||
|
||||
# Find and save all train packages in global TRAIN_PACKAGES for reference
|
||||
find_train_packages(base_train_dir, patch_train_dir)
|
||||
|
||||
# Collect all base train init files
|
||||
base_train_init_files = [
|
||||
f
|
||||
for f in base_train_dir.rglob("__init__.py")
|
||||
if not f.is_relative_to(patch_train_dir)
|
||||
]
|
||||
|
||||
# Get the patching imports in the base train init files
|
||||
dotted_module_prefix = str(patch_train_dir.relative_to(base_dir)).replace("/", ".")
|
||||
patching_imports = get_file_module_imports(
|
||||
base_train_init_files, module_match_string=dotted_module_prefix
|
||||
)
|
||||
|
||||
# Collect all violations based off the patching imports
|
||||
violations = check_violations(patching_imports, patch_train_dir)
|
||||
if violations:
|
||||
print("\n".join(violations))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -0,0 +1,30 @@
|
||||
# isort: off
|
||||
try:
|
||||
import tensorflow as tf # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"TensorFlow isn't installed. To install TensorFlow, run 'pip install "
|
||||
"tensorflow'."
|
||||
)
|
||||
# isort: on
|
||||
|
||||
from ray.train.tensorflow.config import TensorflowConfig
|
||||
from ray.train.tensorflow.tensorflow_checkpoint import TensorflowCheckpoint
|
||||
from ray.train.tensorflow.tensorflow_trainer import TensorflowTrainer
|
||||
from ray.train.tensorflow.train_loop_utils import prepare_dataset_shard
|
||||
from ray.train.v2._internal.constants import is_v2_enabled
|
||||
|
||||
if is_v2_enabled():
|
||||
from ray.train.v2.tensorflow.tensorflow_trainer import ( # noqa: F811
|
||||
TensorflowTrainer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TensorflowCheckpoint",
|
||||
"TensorflowConfig",
|
||||
"prepare_dataset_shard",
|
||||
"TensorflowTrainer",
|
||||
]
|
||||
|
||||
|
||||
# DO NOT ADD ANYTHING AFTER THIS LINE.
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import ray
|
||||
from ray._common.network_utils import build_address
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train._internal.utils import get_address_and_port
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.v2._internal.util import TrainingFramework
|
||||
from ray.util import PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
@dataclass
|
||||
class TensorflowConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return _TensorflowBackend
|
||||
|
||||
@property
|
||||
def framework(self):
|
||||
return TrainingFramework.TENSORFLOW
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _setup_tensorflow_environment(worker_addresses: List[str], index: int):
|
||||
"""Set up distributed Tensorflow training information.
|
||||
|
||||
This function should be called on each worker.
|
||||
|
||||
Args:
|
||||
worker_addresses: Addresses of all the workers.
|
||||
index: Index (i.e. world rank) of the current worker.
|
||||
"""
|
||||
tf_config = {
|
||||
"cluster": {"worker": worker_addresses},
|
||||
"task": {"type": "worker", "index": index},
|
||||
}
|
||||
os.environ["TF_CONFIG"] = json.dumps(tf_config)
|
||||
os.environ["TF_USE_LEGACY_KERAS"] = "1"
|
||||
|
||||
|
||||
class _TensorflowBackend(Backend):
|
||||
def on_start(self, worker_group: BaseWorkerGroup, backend_config: TensorflowConfig):
|
||||
# Compute URL for initializing distributed setup.
|
||||
def get_url():
|
||||
address, port = get_address_and_port()
|
||||
return build_address(address, port)
|
||||
|
||||
urls = worker_group.execute(get_url)
|
||||
|
||||
# Get setup tasks in order to throw errors on failure.
|
||||
setup_futures = []
|
||||
for i in range(len(worker_group)):
|
||||
setup_futures.append(
|
||||
worker_group.execute_single_async(
|
||||
i,
|
||||
_setup_tensorflow_environment,
|
||||
worker_addresses=urls,
|
||||
index=i,
|
||||
)
|
||||
)
|
||||
ray.get(setup_futures)
|
||||
@@ -0,0 +1,210 @@
|
||||
import shutil
|
||||
from abc import abstractmethod
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from tensorflow.keras.callbacks import Callback as KerasCallback
|
||||
|
||||
import ray
|
||||
from ray.train.tensorflow import TensorflowCheckpoint
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
class _Callback(KerasCallback):
|
||||
"""Base class for Ray Train's Keras callbacks."""
|
||||
|
||||
_allowed = [
|
||||
"epoch_begin",
|
||||
"epoch_end",
|
||||
"train_batch_begin",
|
||||
"train_batch_end",
|
||||
"test_batch_begin",
|
||||
"test_batch_end",
|
||||
"predict_batch_begin",
|
||||
"predict_batch_end",
|
||||
"train_begin",
|
||||
"train_end",
|
||||
"test_begin",
|
||||
"test_end",
|
||||
"predict_begin",
|
||||
"predict_end",
|
||||
]
|
||||
|
||||
def __init__(self, on: Union[str, List[str]] = "validation_end"):
|
||||
super(_Callback, self).__init__()
|
||||
|
||||
if not isinstance(on, list):
|
||||
on = [on]
|
||||
if any(w not in self._allowed for w in on):
|
||||
raise ValueError(
|
||||
"Invalid trigger time selected: {}. Must be one of {}".format(
|
||||
on, self._allowed
|
||||
)
|
||||
)
|
||||
self._on = on
|
||||
|
||||
def _handle(self, logs: Dict, when: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def on_epoch_begin(self, epoch, logs=None):
|
||||
if "epoch_begin" in self._on:
|
||||
self._handle(logs, "epoch_begin")
|
||||
|
||||
def on_epoch_end(self, epoch, logs=None):
|
||||
if "epoch_end" in self._on:
|
||||
self._handle(logs, "epoch_end")
|
||||
|
||||
def on_train_batch_begin(self, batch, logs=None):
|
||||
if "train_batch_begin" in self._on:
|
||||
self._handle(logs, "train_batch_begin")
|
||||
|
||||
def on_train_batch_end(self, batch, logs=None):
|
||||
if "train_batch_end" in self._on:
|
||||
self._handle(logs, "train_batch_end")
|
||||
|
||||
def on_test_batch_begin(self, batch, logs=None):
|
||||
if "test_batch_begin" in self._on:
|
||||
self._handle(logs, "test_batch_begin")
|
||||
|
||||
def on_test_batch_end(self, batch, logs=None):
|
||||
if "test_batch_end" in self._on:
|
||||
self._handle(logs, "test_batch_end")
|
||||
|
||||
def on_predict_batch_begin(self, batch, logs=None):
|
||||
if "predict_batch_begin" in self._on:
|
||||
self._handle(logs, "predict_batch_begin")
|
||||
|
||||
def on_predict_batch_end(self, batch, logs=None):
|
||||
if "predict_batch_end" in self._on:
|
||||
self._handle(logs, "predict_batch_end")
|
||||
|
||||
def on_train_begin(self, logs=None):
|
||||
if "train_begin" in self._on:
|
||||
self._handle(logs, "train_begin")
|
||||
|
||||
def on_train_end(self, logs=None):
|
||||
if "train_end" in self._on:
|
||||
self._handle(logs, "train_end")
|
||||
|
||||
def on_test_begin(self, logs=None):
|
||||
if "test_begin" in self._on:
|
||||
self._handle(logs, "test_begin")
|
||||
|
||||
def on_test_end(self, logs=None):
|
||||
if "test_end" in self._on:
|
||||
self._handle(logs, "test_end")
|
||||
|
||||
def on_predict_begin(self, logs=None):
|
||||
if "predict_begin" in self._on:
|
||||
self._handle(logs, "predict_begin")
|
||||
|
||||
def on_predict_end(self, logs=None):
|
||||
if "predict_end" in self._on:
|
||||
self._handle(logs, "predict_end")
|
||||
|
||||
|
||||
class RayReportCallback(_Callback):
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_on: Union[str, List[str]] = "epoch_end",
|
||||
report_metrics_on: Union[str, List[str]] = "epoch_end",
|
||||
metrics: Optional[Union[str, List[str], Dict[str, str]]] = None,
|
||||
):
|
||||
if isinstance(checkpoint_on, str):
|
||||
checkpoint_on = [checkpoint_on]
|
||||
if isinstance(report_metrics_on, str):
|
||||
report_metrics_on = [report_metrics_on]
|
||||
|
||||
on = list(set(checkpoint_on + report_metrics_on))
|
||||
super().__init__(on=on)
|
||||
|
||||
self._checkpoint_on: List[str] = checkpoint_on
|
||||
self._report_metrics_on: List[str] = report_metrics_on
|
||||
self._metrics = metrics
|
||||
|
||||
def _get_reported_metrics(self, logs: Dict) -> Dict:
|
||||
assert isinstance(self._metrics, (type(None), str, list, dict))
|
||||
|
||||
if self._metrics is None:
|
||||
reported_metrics = logs
|
||||
elif isinstance(self._metrics, str):
|
||||
reported_metrics = {self._metrics: logs[self._metrics]}
|
||||
elif isinstance(self._metrics, list):
|
||||
reported_metrics = {metric: logs[metric] for metric in self._metrics}
|
||||
elif isinstance(self._metrics, dict):
|
||||
reported_metrics = {
|
||||
key: logs[metric] for key, metric in self._metrics.items()
|
||||
}
|
||||
|
||||
assert isinstance(reported_metrics, dict)
|
||||
return reported_metrics
|
||||
|
||||
@abstractmethod
|
||||
def _save_and_report_checkpoint(
|
||||
self, metrics: Dict, checkpoint: TensorflowCheckpoint
|
||||
):
|
||||
"""Save checkpoint and report metrics corresonding to this checkpoint."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _report_metrics(self, metrics: Dict):
|
||||
"""Report metrics."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _handle(self, logs: Dict, when: str):
|
||||
assert when in self._checkpoint_on or when in self._report_metrics_on
|
||||
|
||||
metrics = self._get_reported_metrics(logs)
|
||||
|
||||
should_checkpoint = when in self._checkpoint_on
|
||||
if should_checkpoint:
|
||||
checkpoint = TensorflowCheckpoint.from_model(self.model)
|
||||
self._save_and_report_checkpoint(metrics, checkpoint)
|
||||
# Clean up temporary checkpoint
|
||||
shutil.rmtree(checkpoint.path, ignore_errors=True)
|
||||
else:
|
||||
self._report_metrics(metrics)
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
class ReportCheckpointCallback(RayReportCallback):
|
||||
"""Keras callback for Ray Train reporting and checkpointing.
|
||||
|
||||
.. note::
|
||||
Metrics are always reported with checkpoints, even if the event isn't specified
|
||||
in ``report_metrics_on``.
|
||||
|
||||
Example:
|
||||
.. testcode:: python
|
||||
|
||||
############# Using it in TrainSession ###############
|
||||
from ray.air.integrations.keras import ReportCheckpointCallback
|
||||
def train_loop_per_worker():
|
||||
strategy = tf.distribute.MultiWorkerMirroredStrategy()
|
||||
with strategy.scope():
|
||||
model = build_model()
|
||||
|
||||
model.fit(dataset_shard, callbacks=[ReportCheckpointCallback()])
|
||||
|
||||
Args:
|
||||
metrics: Metrics to report. If this is a list, each item describes
|
||||
the metric key reported to Keras, and it's reported under the
|
||||
same name. If this is a dict, each key is the name reported
|
||||
and the respective value is the metric key reported to Keras.
|
||||
If this is None, all Keras logs are reported.
|
||||
report_metrics_on: When to report metrics. Must be one of
|
||||
the Keras event hooks (less the ``on_``), e.g.
|
||||
"train_start" or "predict_end". Defaults to "epoch_end".
|
||||
checkpoint_on: When to save checkpoints. Must be one of the Keras event hooks
|
||||
(less the ``on_``), e.g. "train_start" or "predict_end". Defaults to
|
||||
"epoch_end".
|
||||
"""
|
||||
|
||||
def _save_and_report_checkpoint(
|
||||
self, metrics: Dict, checkpoint: TensorflowCheckpoint
|
||||
):
|
||||
"""Save checkpoint and report metrics corresonding to this checkpoint."""
|
||||
ray.train.report(metrics, checkpoint=checkpoint)
|
||||
|
||||
def _report_metrics(self, metrics: Dict):
|
||||
"""Report metrics."""
|
||||
ray.train.report(metrics, checkpoint=None)
|
||||
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import tensorflow as tf
|
||||
from tensorflow import keras
|
||||
|
||||
from ray.train._internal.framework_checkpoint import FrameworkCheckpoint
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class TensorflowCheckpoint(FrameworkCheckpoint):
|
||||
"""A :py:class:`~ray.train.Checkpoint` with TensorFlow-specific functionality."""
|
||||
|
||||
MODEL_FILENAME_KEY = "_model_filename"
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
cls,
|
||||
model: keras.Model,
|
||||
*,
|
||||
preprocessor: Optional["Preprocessor"] = None,
|
||||
) -> "TensorflowCheckpoint":
|
||||
"""Create a :py:class:`~ray.train.Checkpoint` that stores a Keras model.
|
||||
|
||||
The checkpoint created with this method needs to be paired with
|
||||
`model` when used.
|
||||
|
||||
Args:
|
||||
model: The Keras model, whose weights are stored in the checkpoint.
|
||||
preprocessor: A fitted preprocessor to be applied before inference.
|
||||
|
||||
Returns:
|
||||
A :py:class:`TensorflowCheckpoint` containing the specified model.
|
||||
|
||||
Examples:
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray.train.tensorflow import TensorflowCheckpoint
|
||||
import tensorflow as tf
|
||||
|
||||
model = tf.keras.applications.resnet.ResNet101()
|
||||
checkpoint = TensorflowCheckpoint.from_model(model)
|
||||
|
||||
.. testoutput::
|
||||
:options: +MOCK
|
||||
:hide:
|
||||
|
||||
... # Model may or may not be downloaded
|
||||
|
||||
"""
|
||||
tempdir = tempfile.mkdtemp()
|
||||
filename = "model.keras"
|
||||
model.save(Path(tempdir, filename).as_posix())
|
||||
|
||||
checkpoint = cls.from_directory(tempdir)
|
||||
if preprocessor:
|
||||
checkpoint.set_preprocessor(preprocessor)
|
||||
checkpoint.update_metadata({cls.MODEL_FILENAME_KEY: filename})
|
||||
return checkpoint
|
||||
|
||||
@classmethod
|
||||
def from_h5(
|
||||
cls, file_path: str, *, preprocessor: Optional["Preprocessor"] = None
|
||||
) -> "TensorflowCheckpoint":
|
||||
"""Create a :py:class:`~ray.train.Checkpoint` that stores a Keras
|
||||
model from H5 format.
|
||||
|
||||
The checkpoint generated by this method contains all the information needed.
|
||||
Thus no `model` is needed to be supplied when using this checkpoint.
|
||||
|
||||
Args:
|
||||
file_path: The path to the .h5 file to load model from. This is the
|
||||
same path that is used for ``model.save(path)``.
|
||||
preprocessor: A fitted preprocessor to be applied before inference.
|
||||
|
||||
Returns:
|
||||
A :py:class:`TensorflowCheckpoint` converted from h5 format.
|
||||
|
||||
"""
|
||||
if not os.path.isfile(file_path) or not file_path.endswith(".h5"):
|
||||
raise ValueError(
|
||||
"Please supply a h5 file path to `TensorflowCheckpoint.from_h5()`."
|
||||
)
|
||||
tempdir = tempfile.mkdtemp()
|
||||
filename = os.path.basename(file_path)
|
||||
new_checkpoint_file = Path(tempdir, filename).as_posix()
|
||||
shutil.copy(file_path, new_checkpoint_file)
|
||||
|
||||
checkpoint = cls.from_directory(tempdir)
|
||||
if preprocessor:
|
||||
checkpoint.set_preprocessor(preprocessor)
|
||||
checkpoint.update_metadata({cls.MODEL_FILENAME_KEY: filename})
|
||||
return checkpoint
|
||||
|
||||
@classmethod
|
||||
def from_saved_model(
|
||||
cls, dir_path: str, *, preprocessor: Optional["Preprocessor"] = None
|
||||
) -> "TensorflowCheckpoint":
|
||||
"""Create a :py:class:`~ray.train.Checkpoint` that stores a Keras
|
||||
model from SavedModel format.
|
||||
|
||||
The checkpoint generated by this method contains all the information needed.
|
||||
Thus no `model` is needed to be supplied when using this checkpoint.
|
||||
|
||||
Args:
|
||||
dir_path: The directory containing the saved model. This is the same
|
||||
directory as used by ``model.save(dir_path)``.
|
||||
preprocessor: A fitted preprocessor to be applied before inference.
|
||||
|
||||
Returns:
|
||||
A :py:class:`TensorflowCheckpoint` converted from SavedModel format.
|
||||
|
||||
"""
|
||||
if not os.path.isdir(dir_path):
|
||||
raise ValueError(
|
||||
"Please supply a directory to `TensorflowCheckpoint.from_saved_model`"
|
||||
)
|
||||
tempdir = tempfile.mkdtemp()
|
||||
# TODO(ml-team): Replace this with copytree()
|
||||
os.rmdir(tempdir)
|
||||
shutil.copytree(dir_path, tempdir)
|
||||
|
||||
checkpoint = cls.from_directory(tempdir)
|
||||
if preprocessor:
|
||||
checkpoint.set_preprocessor(preprocessor)
|
||||
# NOTE: The entire directory is the checkpoint.
|
||||
checkpoint.update_metadata({cls.MODEL_FILENAME_KEY: "."})
|
||||
return checkpoint
|
||||
|
||||
def get_model(
|
||||
self,
|
||||
) -> tf.keras.Model:
|
||||
"""Retrieve the model stored in this checkpoint.
|
||||
|
||||
Returns:
|
||||
The Tensorflow Keras model stored in the checkpoint.
|
||||
"""
|
||||
metadata = self.get_metadata()
|
||||
if self.MODEL_FILENAME_KEY not in metadata:
|
||||
raise ValueError(
|
||||
"`TensorflowCheckpoint` cannot retrieve the model if you override the "
|
||||
"checkpoint metadata. Please use `Checkpoint.update_metadata` instead."
|
||||
)
|
||||
model_filename = metadata[self.MODEL_FILENAME_KEY]
|
||||
with self.as_directory() as checkpoint_dir:
|
||||
model_path = Path(checkpoint_dir, model_filename).as_posix()
|
||||
return keras.models.load_model(model_path)
|
||||
@@ -0,0 +1,191 @@
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
|
||||
from ray.train import Checkpoint, DataConfig, RunConfig, ScalingConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tensorflow.config import TensorflowConfig
|
||||
from ray.train.trainer import GenDataset
|
||||
from ray.util import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class TensorflowTrainer(DataParallelTrainer):
|
||||
"""A Trainer for data parallel Tensorflow training.
|
||||
|
||||
This Trainer runs the function ``train_loop_per_worker`` on multiple Ray
|
||||
Actors. These actors already have the necessary TensorFlow process group already
|
||||
configured for distributed TensorFlow training.
|
||||
|
||||
The ``train_loop_per_worker`` function is expected to take in either 0 or 1
|
||||
arguments:
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker():
|
||||
...
|
||||
|
||||
.. testcode::
|
||||
|
||||
def train_loop_per_worker(config: Dict):
|
||||
...
|
||||
|
||||
If ``train_loop_per_worker`` accepts an argument, then
|
||||
``train_loop_config`` will be passed in as the argument. This is useful if you
|
||||
want to tune the values in ``train_loop_config`` as hyperparameters.
|
||||
|
||||
If the ``datasets`` dict contains a training dataset (denoted by
|
||||
the "train" key), then it will be split into multiple dataset
|
||||
shards that can then be accessed by ``ray.train.get_dataset_shard("train")`` inside
|
||||
``train_loop_per_worker``. All the other datasets will not be split and
|
||||
``ray.train.get_dataset_shard(...)`` will return the entire Dataset.
|
||||
|
||||
Inside the ``train_loop_per_worker`` function, you can use any of the
|
||||
:ref:`Ray Train loop methods <train-loop-api>`.
|
||||
|
||||
.. warning::
|
||||
Ray will not automatically set any environment variables or configuration
|
||||
related to local parallelism / threading
|
||||
:ref:`aside from "OMP_NUM_THREADS" <omp-num-thread-note>`.
|
||||
If you desire greater control over TensorFlow threading, use
|
||||
the ``tf.config.threading`` module (eg.
|
||||
``tf.config.threading.set_inter_op_parallelism_threads(num_cpus)``)
|
||||
at the beginning of your ``train_loop_per_worker`` function.
|
||||
|
||||
|
||||
.. testcode::
|
||||
|
||||
from ray import train
|
||||
|
||||
def train_loop_per_worker():
|
||||
# Report intermediate results for callbacks or logging and
|
||||
# checkpoint data.
|
||||
train.report(...)
|
||||
|
||||
# Returns dict of last saved checkpoint.
|
||||
train.get_checkpoint()
|
||||
|
||||
# Returns the Dataset shard for the given key.
|
||||
train.get_dataset_shard("my_dataset")
|
||||
|
||||
# Returns the total number of workers executing training.
|
||||
train.get_context().get_world_size()
|
||||
|
||||
# Returns the rank of this worker.
|
||||
train.get_context().get_world_rank()
|
||||
|
||||
# Returns the rank of the worker on the current node.
|
||||
train.get_context().get_local_rank()
|
||||
|
||||
Any returns from the ``train_loop_per_worker`` will be discarded and not
|
||||
used or persisted anywhere.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import tensorflow as tf
|
||||
import numpy as np
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
def build_model():
|
||||
# toy neural network : 1-layer
|
||||
return tf.keras.Sequential(
|
||||
[tf.keras.layers.Dense(
|
||||
1, activation="linear", input_shape=(1,))]
|
||||
)
|
||||
|
||||
def train_loop_per_worker(config):
|
||||
dataset_shard = train.get_dataset_shard("train")
|
||||
strategy = tf.distribute.experimental.MultiWorkerMirroredStrategy()
|
||||
with strategy.scope():
|
||||
model = build_model()
|
||||
model.compile(
|
||||
optimizer="Adam", loss="mean_squared_error", metrics=["mse"])
|
||||
|
||||
tf_dataset = dataset_shard.to_tf(
|
||||
feature_columns="x",
|
||||
label_columns="y",
|
||||
batch_size=1
|
||||
)
|
||||
for epoch in range(config["num_epochs"]):
|
||||
model.fit(tf_dataset)
|
||||
|
||||
# Create checkpoint.
|
||||
checkpoint_dir = tempfile.mkdtemp()
|
||||
model.save_weights(
|
||||
os.path.join(checkpoint_dir, "my_checkpoint")
|
||||
)
|
||||
checkpoint = Checkpoint.from_directory(checkpoint_dir)
|
||||
|
||||
train.report(
|
||||
{},
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
|
||||
train_dataset = ray.data.from_items([{"x": np.array([x], dtype=np.float32), "y": x + 1} for x in range(32)])
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
scaling_config=ScalingConfig(num_workers=3, use_gpu=True),
|
||||
datasets={"train": train_dataset},
|
||||
train_loop_config={"num_epochs": 2},
|
||||
)
|
||||
result = trainer.fit()
|
||||
|
||||
.. testoutput::
|
||||
:options:+ELLIPSIS
|
||||
:hide:
|
||||
|
||||
...
|
||||
|
||||
Args:
|
||||
train_loop_per_worker: The training function to execute.
|
||||
This can either take in no arguments or a ``config`` dict.
|
||||
train_loop_config: Configurations to pass into
|
||||
``train_loop_per_worker`` if it accepts an argument.
|
||||
tensorflow_config: Configuration for setting up the TensorFlow backend.
|
||||
If set to None, use the default configuration. This replaces the
|
||||
``backend_config`` arg of ``DataParallelTrainer``.
|
||||
scaling_config: Configuration for how to scale data parallel training.
|
||||
dataset_config: Configuration for dataset ingest.
|
||||
run_config: Configuration for the execution of the training run.
|
||||
datasets: Any Datasets to use for training. Use
|
||||
the key "train" to denote which dataset is the training
|
||||
dataset.
|
||||
metadata: Dict that should be made available via
|
||||
`ray.train.get_context().get_metadata()` and in `checkpoint.get_metadata()`
|
||||
for checkpoints saved from this Trainer. Must be JSON-serializable.
|
||||
resume_from_checkpoint: A checkpoint to resume training from.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_loop_per_worker: Union[Callable[[], None], Callable[[Dict], None]],
|
||||
*,
|
||||
train_loop_config: Optional[Dict] = None,
|
||||
tensorflow_config: Optional[TensorflowConfig] = None,
|
||||
scaling_config: Optional[ScalingConfig] = None,
|
||||
dataset_config: Optional[DataConfig] = None,
|
||||
run_config: Optional[RunConfig] = None,
|
||||
datasets: Optional[Dict[str, GenDataset]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
resume_from_checkpoint: Optional[Checkpoint] = None,
|
||||
):
|
||||
if not tensorflow_config:
|
||||
tensorflow_config = TensorflowConfig()
|
||||
|
||||
super(TensorflowTrainer, self).__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
train_loop_config=train_loop_config,
|
||||
backend_config=tensorflow_config,
|
||||
scaling_config=scaling_config,
|
||||
dataset_config=dataset_config,
|
||||
run_config=run_config,
|
||||
datasets=datasets,
|
||||
resume_from_checkpoint=resume_from_checkpoint,
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
import tensorflow as tf
|
||||
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
def prepare_dataset_shard(tf_dataset_shard: tf.data.Dataset):
|
||||
"""A utility function that overrides default config for Tensorflow Dataset.
|
||||
|
||||
This should be used on a TensorFlow ``Dataset`` created by calling
|
||||
``iter_tf_batches()`` on a ``ray.data.Dataset`` returned by
|
||||
``ray.train.get_dataset_shard()`` since the dataset has already
|
||||
been sharded across the workers.
|
||||
|
||||
Args:
|
||||
tf_dataset_shard: A TensorFlow Dataset.
|
||||
|
||||
Returns:
|
||||
A TensorFlow Dataset with:
|
||||
- autosharding turned off
|
||||
- prefetching turned on with autotune enabled
|
||||
"""
|
||||
options = tf.data.Options()
|
||||
options.experimental_distribute.auto_shard_policy = (
|
||||
tf.data.experimental.AutoShardPolicy.OFF
|
||||
)
|
||||
return tf_dataset_shard.with_options(options).prefetch(tf.data.AUTOTUNE)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,143 @@
|
||||
import logging
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import simulate_s3_bucket
|
||||
from ray.cluster_utils import Cluster
|
||||
|
||||
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
|
||||
from ray.tests.conftest import (
|
||||
propagate_logs, # noqa
|
||||
pytest_runtest_makereport, # noqa
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_1_cpu_1_gpu():
|
||||
address_info = ray.init(num_cpus=1, num_gpus=1)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_2_gpus():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shutdown_only():
|
||||
yield None
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_gpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=4, num_gpus=2)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_neuron_cores():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=4, resources={"neuron_cores": 2})
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_4_node_4_cpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(4):
|
||||
cluster.add_node(num_cpus=4)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_4_gpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=2, num_gpus=4)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_2_node_2_cpu():
|
||||
cluster = Cluster()
|
||||
for _ in range(2):
|
||||
cluster.add_node(num_cpus=2)
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
|
||||
yield
|
||||
|
||||
ray.shutdown()
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_bucket_uri():
|
||||
from ray.air._internal.uri_utils import URI
|
||||
|
||||
port = 5002
|
||||
region = "us-west-2"
|
||||
with simulate_s3_bucket(port=port, region=region) as s3_uri:
|
||||
s3 = boto3.client(
|
||||
"s3", region_name=region, endpoint_url=f"http://localhost:{port}"
|
||||
)
|
||||
# Bucket name will be autogenerated/unique per test
|
||||
bucket_name = URI(s3_uri).name
|
||||
s3.create_bucket(
|
||||
Bucket=bucket_name,
|
||||
CreateBucketConfiguration={"LocationConstraint": region},
|
||||
)
|
||||
# Disable server HTTP request logging
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
yield s3_uri
|
||||
logging.getLogger("werkzeug").setLevel(logging.INFO)
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
|
||||
|
||||
class DummyPreprocessor(Preprocessor):
|
||||
_is_fittable = False
|
||||
|
||||
def __init__(self, transform=None):
|
||||
self.id = uuid.uuid4()
|
||||
|
||||
if transform is None:
|
||||
self.transform = lambda b: b
|
||||
else:
|
||||
self.transform = transform
|
||||
|
||||
def transform_batch(self, batch):
|
||||
self._batch_transformed = True
|
||||
return self.transform(batch)
|
||||
|
||||
def _transform_pandas(self, df):
|
||||
return df
|
||||
|
||||
@property
|
||||
def has_preprocessed(self):
|
||||
return hasattr(self, "_batch_transformed")
|
||||
|
||||
def __eq__(self, other_preprocessor):
|
||||
return self.id == other_preprocessor.id
|
||||
@@ -0,0 +1,171 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.utils.data import DataLoader
|
||||
from torchmetrics import Accuracy
|
||||
|
||||
from ray import train
|
||||
from ray.train.lightning._lightning_utils import import_lightning
|
||||
|
||||
pl = import_lightning()
|
||||
|
||||
|
||||
class LinearModule(pl.LightningModule):
|
||||
def __init__(self, input_dim, output_dim, strategy="ddp", fail_epoch=-1) -> None:
|
||||
super().__init__()
|
||||
self.linear = nn.Linear(input_dim, output_dim)
|
||||
self.loss = []
|
||||
self.strategy = strategy
|
||||
self.restored = train.get_checkpoint() is not None
|
||||
self.fail_epoch = fail_epoch
|
||||
|
||||
def forward(self, input):
|
||||
if isinstance(input, dict) and len(input) == 1:
|
||||
input = list(input.values())[0]
|
||||
return self.linear(input)
|
||||
|
||||
def training_step(self, batch):
|
||||
if not self.restored and self.fail_epoch == self.current_epoch:
|
||||
raise RuntimeError
|
||||
|
||||
output = self.forward(batch)
|
||||
loss = torch.sum(output)
|
||||
self.log("loss", loss)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
loss = self.forward(val_batch)
|
||||
self.loss.append(loss)
|
||||
return {"val_loss": loss}
|
||||
|
||||
def test_step(self, batch, batch_idx):
|
||||
loss = self.forward(batch)
|
||||
return {"test_loss": loss}
|
||||
|
||||
def on_validation_epoch_end(self) -> None:
|
||||
avg_loss = torch.stack(self.loss).mean()
|
||||
self.log("val_loss", avg_loss)
|
||||
self.loss.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx):
|
||||
return self.forward(batch)
|
||||
|
||||
def configure_optimizers(self):
|
||||
if self.strategy == "fsdp":
|
||||
# Feed FSDP wrapped model parameters to optimizer
|
||||
return torch.optim.AdamW(self.trainer.model.parameters(), lr=0.1)
|
||||
else:
|
||||
return torch.optim.AdamW(self.parameters(), lr=0.1)
|
||||
|
||||
|
||||
class DoubleLinearModule(pl.LightningModule):
|
||||
def __init__(self, input_dim_1, input_dim_2, output_dim) -> None:
|
||||
super().__init__()
|
||||
self.linear_1 = nn.Linear(input_dim_1, output_dim)
|
||||
self.linear_2 = nn.Linear(input_dim_2, output_dim)
|
||||
self.loss = []
|
||||
|
||||
def forward(self, batch):
|
||||
input_1 = batch["input_1"]
|
||||
input_2 = batch["input_2"]
|
||||
return self.linear_1(input_1) + self.linear_2(input_2)
|
||||
|
||||
def training_step(self, batch):
|
||||
output = self.forward(batch)
|
||||
loss = torch.sum(output)
|
||||
self.log("loss", loss)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
loss = self.forward(val_batch)
|
||||
self.loss.append(loss)
|
||||
return {"val_loss": loss}
|
||||
|
||||
def on_validation_epoch_end(self) -> None:
|
||||
print("Validation Epoch:", self.current_epoch)
|
||||
avg_loss = torch.stack(self.loss).mean()
|
||||
self.log("val_loss", avg_loss)
|
||||
self.loss.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx):
|
||||
return self.forward(batch)
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.AdamW(self.parameters(), lr=0.1)
|
||||
|
||||
|
||||
class DummyDataModule(pl.LightningDataModule):
|
||||
def __init__(self, batch_size: int = 8, dataset_size: int = 256) -> None:
|
||||
super().__init__()
|
||||
self.batch_size = batch_size
|
||||
self.train_data = torch.randn(dataset_size, 32)
|
||||
self.val_data = torch.randn(dataset_size, 32)
|
||||
self.test_data = torch.randn(dataset_size, 32)
|
||||
|
||||
def train_dataloader(self):
|
||||
return DataLoader(self.train_data, batch_size=self.batch_size)
|
||||
|
||||
def val_dataloader(self):
|
||||
return DataLoader(self.val_data, batch_size=self.batch_size)
|
||||
|
||||
def test_dataloader(self):
|
||||
return DataLoader(self.test_data, batch_size=self.batch_size)
|
||||
|
||||
|
||||
class LightningMNISTClassifier(pl.LightningModule):
|
||||
def __init__(self, lr: float, layer_1: int, layer_2: int):
|
||||
super(LightningMNISTClassifier, self).__init__()
|
||||
|
||||
self.lr = lr
|
||||
# mnist images are (1, 28, 28) (channels, width, height)
|
||||
self.layer_1 = torch.nn.Linear(28 * 28, layer_1)
|
||||
self.layer_2 = torch.nn.Linear(layer_1, layer_2)
|
||||
self.layer_3 = torch.nn.Linear(layer_2, 10)
|
||||
self.accuracy = Accuracy(task="multiclass", num_classes=10, top_k=1)
|
||||
self.val_acc_list = []
|
||||
self.val_loss_list = []
|
||||
|
||||
def forward(self, x):
|
||||
batch_size, channels, width, height = x.size()
|
||||
x = x.view(batch_size, -1)
|
||||
x = self.layer_1(x)
|
||||
x = torch.relu(x)
|
||||
x = self.layer_2(x)
|
||||
x = torch.relu(x)
|
||||
x = self.layer_3(x)
|
||||
x = torch.log_softmax(x, dim=1)
|
||||
return x
|
||||
|
||||
def configure_optimizers(self):
|
||||
return torch.optim.Adam(self.parameters(), lr=self.lr)
|
||||
|
||||
def training_step(self, train_batch, batch_idx):
|
||||
x, y = train_batch
|
||||
logits = self.forward(x)
|
||||
loss = F.nll_loss(logits, y)
|
||||
acc = self.accuracy(logits, y)
|
||||
self.log("ptl/train_loss", loss)
|
||||
self.log("ptl/train_accuracy", acc)
|
||||
return loss
|
||||
|
||||
def validation_step(self, val_batch, batch_idx):
|
||||
x, y = val_batch
|
||||
logits = self.forward(x)
|
||||
loss = F.nll_loss(logits, y)
|
||||
acc = self.accuracy(logits, y)
|
||||
self.val_acc_list.append(acc)
|
||||
self.val_loss_list.append(loss)
|
||||
return {"val_loss": loss, "val_accuracy": acc}
|
||||
|
||||
def on_validation_epoch_end(self):
|
||||
avg_loss = torch.stack(self.val_loss_list).mean()
|
||||
avg_acc = torch.stack(self.val_acc_list).mean()
|
||||
self.log("ptl/val_loss", avg_loss)
|
||||
self.log("ptl/val_accuracy", avg_acc)
|
||||
self.val_acc_list.clear()
|
||||
self.val_loss_list.clear()
|
||||
|
||||
def predict_step(self, batch, batch_idx, dataloader_idx=None):
|
||||
x = batch
|
||||
logits = self.forward(x)
|
||||
return torch.argmax(logits, dim=-1)
|
||||
@@ -0,0 +1,158 @@
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
import ray.train
|
||||
import ray.tune
|
||||
from ray.train.constants import ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.util.annotations import RayDeprecationWarning
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_v2_migration_deprecation_messages(monkeypatch):
|
||||
monkeypatch.setenv(ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR, "1")
|
||||
yield
|
||||
monkeypatch.delenv(ENABLE_V2_MIGRATION_WARNINGS_ENV_VAR)
|
||||
|
||||
|
||||
def test_trainer_restore():
|
||||
with pytest.warns(RayDeprecationWarning, match="restore"):
|
||||
try:
|
||||
DataParallelTrainer.restore("dummy")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="can_restore"):
|
||||
try:
|
||||
DataParallelTrainer.can_restore("dummy")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_trainer_valid_configs(ray_start_4_cpus, tmp_path):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
run_config=ray.train.RunConfig(
|
||||
storage_path=tmp_path,
|
||||
failure_config=ray.train.FailureConfig(max_failures=1),
|
||||
),
|
||||
).fit()
|
||||
|
||||
for warning in w:
|
||||
assert not (
|
||||
warning.category == RayDeprecationWarning
|
||||
and "`RunConfig` class should be imported from `ray.tune`"
|
||||
in str(warning.message)
|
||||
)
|
||||
|
||||
|
||||
def test_trainer_deprecated_configs():
|
||||
with pytest.warns(RayDeprecationWarning, match="metadata"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
metadata={"dummy": "dummy"},
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="resume_from_checkpoint"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
resume_from_checkpoint=ray.train.Checkpoint.from_directory("dummy"),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="fail_fast"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
failure_config=ray.train.FailureConfig(fail_fast=True)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="trainer_resources"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(trainer_resources={"CPU": 1}),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="verbose"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(verbose=True),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="log_to_file"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(log_to_file=True),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="stop"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(stop={"training_iteration": 1}),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="callbacks"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(callbacks=[ray.tune.Callback()]),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="progress_reporter"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
progress_reporter=ray.tune.ProgressReporter()
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="sync_config"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
run_config=ray.train.RunConfig(
|
||||
sync_config=ray.train.SyncConfig(sync_artifacts=True)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_train_context_deprecations(ray_start_4_cpus, tmp_path):
|
||||
def train_fn_per_worker(config):
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_dir"):
|
||||
ray.train.get_context().get_trial_dir()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_id"):
|
||||
ray.train.get_context().get_trial_id()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_name"):
|
||||
ray.train.get_context().get_trial_name()
|
||||
|
||||
with pytest.warns(RayDeprecationWarning, match="get_trial_resources"):
|
||||
ray.train.get_context().get_trial_resources()
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_fn_per_worker,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
run_config=ray.train.RunConfig(storage_path=tmp_path),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_v2_enabled_error(monkeypatch):
|
||||
"""Running a V1 Trainer with V2 enabled should raise an error."""
|
||||
from ray.train.v2._internal.constants import V2_ENABLED_ENV_VAR
|
||||
|
||||
monkeypatch.setenv(V2_ENABLED_ENV_VAR, "1")
|
||||
|
||||
with pytest.raises(DeprecationWarning, match="Detected use of a deprecated"):
|
||||
DataParallelTrainer(
|
||||
lambda _: None,
|
||||
scaling_config=ray.train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,660 @@
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Set
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray._private.accelerators.neuron import NEURON_RT_VISIBLE_CORES_ENV_VAR
|
||||
from ray.air._internal.util import StartTraceback
|
||||
|
||||
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
|
||||
from ray.tests.conftest import pytest_runtest_makereport # noqa
|
||||
from ray.train import DataConfig
|
||||
from ray.train._internal.backend_executor import (
|
||||
BackendExecutor,
|
||||
InactiveWorkerGroupError,
|
||||
TrainBackendError,
|
||||
TrainingWorkerError,
|
||||
)
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train._internal.worker_group import WorkerGroup, WorkerMetadata
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.constants import (
|
||||
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
|
||||
ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV,
|
||||
JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S,
|
||||
TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S,
|
||||
TRAIN_ENABLE_WORKER_SPREAD_ENV,
|
||||
)
|
||||
from ray.train.torch import TorchConfig
|
||||
from ray.train.v2.jax.config import JaxConfig
|
||||
from ray.util.placement_group import get_current_placement_group
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
from ray.util.state import list_actors
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _start_training(backend_executor: BackendExecutor, fn):
|
||||
storage = StorageContext(
|
||||
storage_path=tempfile.mkdtemp(),
|
||||
experiment_dir_name="exp_name",
|
||||
trial_dir_name="trial_name",
|
||||
)
|
||||
backend_executor.start_training(
|
||||
train_func=fn,
|
||||
datasets={},
|
||||
metadata={},
|
||||
data_config=DataConfig(),
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
|
||||
def gen_execute_special(special_f):
|
||||
def execute_async_special(self, f):
|
||||
"""Runs f on worker 0, special_f on other workers."""
|
||||
futures = [
|
||||
self.workers[0]
|
||||
.actor._RayTrainWorker__execute.options(name=f.__name__)
|
||||
.remote(f)
|
||||
]
|
||||
for worker in self.workers[1:]:
|
||||
futures.append(
|
||||
worker.actor._RayTrainWorker__execute.options(
|
||||
name=special_f.__name__
|
||||
).remote(special_f)
|
||||
)
|
||||
return futures
|
||||
|
||||
return execute_async_special
|
||||
|
||||
|
||||
class TestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return TestBackend
|
||||
|
||||
|
||||
class TestBackend(Backend):
|
||||
def on_start(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
def on_shutdown(self, worker_group: WorkerGroup, backend_config: TestConfig):
|
||||
pass
|
||||
|
||||
|
||||
original_add_workers = WorkerGroup.add_workers
|
||||
|
||||
|
||||
def mock_add_workers(self, num_workers):
|
||||
original_add_workers(self, num_workers)
|
||||
for i, worker in enumerate(self.workers):
|
||||
metadata = WorkerMetadata(
|
||||
node_id=str(i % 2),
|
||||
node_ip=str(i % 2),
|
||||
hostname=0,
|
||||
resource_ids={"GPU": ["0"]},
|
||||
pid=0,
|
||||
)
|
||||
worker.metadata = metadata
|
||||
|
||||
|
||||
def mock_add_workers_to_nodes_with_same_ip(self, num_workers):
|
||||
original_add_workers(self, num_workers)
|
||||
for i, worker in enumerate(self.workers):
|
||||
metadata = WorkerMetadata(
|
||||
node_id=str(i % 2),
|
||||
node_ip=0,
|
||||
hostname=0,
|
||||
resource_ids={"GPU": ["0"]},
|
||||
pid=0,
|
||||
)
|
||||
worker.metadata = metadata
|
||||
|
||||
|
||||
def test_start(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
with pytest.raises(InactiveWorkerGroupError):
|
||||
_start_training(e, lambda: 1)
|
||||
e.start()
|
||||
assert len(e.worker_group) == 2
|
||||
|
||||
|
||||
def test_initialization_hook(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
|
||||
def init_hook():
|
||||
import os
|
||||
|
||||
os.environ["TEST"] = "1"
|
||||
|
||||
e.start(initialization_hook=init_hook)
|
||||
|
||||
def check():
|
||||
import os
|
||||
|
||||
return os.getenv("TEST", "0")
|
||||
|
||||
_start_training(e, check)
|
||||
assert e.finish_training() == ["1", "1"]
|
||||
|
||||
|
||||
def test_shutdown(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
assert len(e.worker_group) == 2
|
||||
e.shutdown()
|
||||
with pytest.raises(InactiveWorkerGroupError):
|
||||
_start_training(e, lambda: 1)
|
||||
|
||||
|
||||
def test_train(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
|
||||
def test_local_ranks(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert set(e.finish_training()) == {0, 1}
|
||||
|
||||
|
||||
def test_local_ranks_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=4)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 1, 0, 1]
|
||||
|
||||
|
||||
def test_local_world_size(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(WorkerGroup, "add_workers", mock_add_workers):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_world_size()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [2, 2, 1]
|
||||
|
||||
|
||||
def test_local_world_size_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(
|
||||
WorkerGroup, "add_workers", mock_add_workers_to_nodes_with_same_ip
|
||||
):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_local_world_size()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [2, 2, 1]
|
||||
|
||||
|
||||
def test_node_ranks(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(WorkerGroup, "add_workers", mock_add_workers):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_node_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 0, 1]
|
||||
|
||||
|
||||
def test_node_ranks_with_same_ip_nodes(ray_2_node_2_cpu):
|
||||
config = TestConfig()
|
||||
with patch.object(
|
||||
WorkerGroup, "add_workers", mock_add_workers_to_nodes_with_same_ip
|
||||
):
|
||||
e = BackendExecutor(config, num_workers=3)
|
||||
e.start()
|
||||
|
||||
def train_func():
|
||||
return train.get_context().get_node_rank()
|
||||
|
||||
_start_training(e, train_func)
|
||||
assert list(e.finish_training()) == [0, 0, 1]
|
||||
|
||||
|
||||
def test_train_failure(ray_start_2_cpus):
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.get_next_results()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.pause_reporting()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.finish_training()
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
_start_training(e, lambda: 2)
|
||||
assert isinstance(exc.value.__cause__, TrainBackendError)
|
||||
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
|
||||
def test_single_worker_user_failure(ray_start_2_cpus):
|
||||
"""Tests if training fails immediately if one worker raises an Exception
|
||||
while executing the user training code."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def single_worker_user_failure():
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
raise RuntimeError
|
||||
else:
|
||||
time.sleep(1000000)
|
||||
|
||||
_start_training(e, single_worker_user_failure)
|
||||
|
||||
with pytest.raises(StartTraceback) as exc:
|
||||
e.get_next_results()
|
||||
assert isinstance(exc.value.__cause__, RuntimeError)
|
||||
|
||||
|
||||
def test_single_worker_actor_failure(ray_start_2_cpus):
|
||||
"""Tests is training fails immediately if one worker actor dies."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def single_worker_actor_failure():
|
||||
if train.get_context().get_world_rank() == 0:
|
||||
# Simulate actor failure
|
||||
os._exit(1)
|
||||
else:
|
||||
time.sleep(1000)
|
||||
|
||||
_start_training(e, single_worker_actor_failure)
|
||||
|
||||
with pytest.raises(TrainingWorkerError):
|
||||
e.get_next_results()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12), reason="tensorflow is not supported in python 3.12+"
|
||||
)
|
||||
def test_tensorflow_start(ray_start_2_cpus):
|
||||
from ray.train.tensorflow import TensorflowConfig
|
||||
|
||||
num_workers = 2
|
||||
tensorflow_config = TensorflowConfig()
|
||||
e = BackendExecutor(tensorflow_config, num_workers=num_workers)
|
||||
e.start()
|
||||
|
||||
def get_tf_config():
|
||||
import json
|
||||
import os
|
||||
|
||||
return json.loads(os.environ["TF_CONFIG"])
|
||||
|
||||
_start_training(e, get_tf_config)
|
||||
results = e.finish_training()
|
||||
assert len(results) == num_workers
|
||||
|
||||
workers = [result["cluster"]["worker"] for result in results]
|
||||
assert all(worker == workers[0] for worker in workers)
|
||||
|
||||
indexes = [result["task"]["index"] for result in results]
|
||||
assert len(set(indexes)) == num_workers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("init_method", ["env", "tcp"])
|
||||
def test_torch_start_shutdown(ray_start_2_cpus, init_method):
|
||||
torch_config = TorchConfig(backend="gloo", init_method=init_method)
|
||||
e = BackendExecutor(torch_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
def check_process_group():
|
||||
import torch
|
||||
|
||||
return (
|
||||
torch.distributed.is_initialized()
|
||||
and torch.distributed.get_world_size() == 2
|
||||
)
|
||||
|
||||
_start_training(e, check_process_group)
|
||||
assert all(e.finish_training())
|
||||
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
_start_training(e, check_process_group)
|
||||
assert not any(e.finish_training())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_method, timeout_s", [("env", 5), ("tcp", 5), ("env", 0), ("tcp", 0)]
|
||||
)
|
||||
def test_torch_process_group_shutdown_timeout(
|
||||
ray_start_2_cpus, monkeypatch, init_method, timeout_s
|
||||
):
|
||||
monkeypatch.setenv(TORCH_PROCESS_GROUP_SHUTDOWN_TIMEOUT_S, timeout_s)
|
||||
torch_config = TorchConfig(backend="gloo", init_method=init_method)
|
||||
e = BackendExecutor(torch_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
# Verify that we do not raise an exception even if we time out
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0, 1]] * 2),
|
||||
(3, [[0]] + [[0, 1]] * 2),
|
||||
(4, [[0, 1]] * 4),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices(ray_2_node_2_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 1}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(
|
||||
2,
|
||||
[[0]] * 2,
|
||||
),
|
||||
(3, [[0, 1]] * 3),
|
||||
(4, [[0, 1]] * 4),
|
||||
(5, [[0]] + [[0, 1]] * 4),
|
||||
(6, [[0]] * 2 + [[0, 1]] * 4),
|
||||
(7, [[0, 1]] * 7),
|
||||
(8, [[0, 1]] * 8),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices_fractional(ray_2_node_2_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
if worker_results[0] != len(worker_results[1]):
|
||||
raise ValueError(
|
||||
"Invalid test parameter. Length of expected result should "
|
||||
"match number of workers."
|
||||
)
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 0.5}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0, 1]]),
|
||||
(2, [[0, 1, 2, 3]] * 2),
|
||||
(3, [[0, 1]] + [[0, 1, 2, 3]] * 2),
|
||||
(4, [[0, 1, 2, 3]] * 4),
|
||||
],
|
||||
)
|
||||
def test_cuda_visible_devices_multiple(ray_2_node_4_gpu, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ["CUDA_VISIBLE_DEVICES"]
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(cuda_visible_devices.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
if worker_results[0] != len(worker_results[1]):
|
||||
raise ValueError(
|
||||
"Invalid test parameter. Length of expected result should "
|
||||
"match number of workers."
|
||||
)
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV] = "1"
|
||||
e = BackendExecutor(
|
||||
config, num_workers=num_workers, resources_per_worker={"GPU": 2}
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0, 1]] * 2),
|
||||
(3, [[0]] + [[0, 1]] * 2),
|
||||
(4, [[0, 1]] * 4),
|
||||
],
|
||||
)
|
||||
def test_neuron_core_accelerator_ids(ray_2_node_2_neuron_cores, worker_results):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
neuron_resource_ids = os.environ[NEURON_RT_VISIBLE_CORES_ENV_VAR]
|
||||
# Sort the runtime ids to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(neuron_resource_ids.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
# sharing enabled by default
|
||||
os.environ.pop(ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV, None)
|
||||
e = BackendExecutor(
|
||||
config,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker={"neuron_cores": 1},
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker_results",
|
||||
[
|
||||
(1, [[0]]),
|
||||
(2, [[0]] + [[1]]),
|
||||
(3, [[0]] * 2 + [[1]]),
|
||||
(4, [[0]] * 2 + [[1]] * 2),
|
||||
],
|
||||
)
|
||||
def test_neuron_core_accelerator_ids_sharing_disabled(
|
||||
ray_2_node_2_neuron_cores, worker_results
|
||||
):
|
||||
config = TestConfig()
|
||||
|
||||
def get_resources():
|
||||
neuron_resource_ids = os.environ[NEURON_RT_VISIBLE_CORES_ENV_VAR]
|
||||
# Sort the runtime ids to have exact match with expected result.
|
||||
sorted_devices = [
|
||||
int(device) for device in sorted(neuron_resource_ids.split(","))
|
||||
]
|
||||
return sorted_devices
|
||||
|
||||
num_workers, expected_results = worker_results
|
||||
|
||||
os.environ[ENABLE_SHARE_NEURON_CORES_ACCELERATOR_ENV] = "0"
|
||||
e = BackendExecutor(
|
||||
config,
|
||||
num_workers=num_workers,
|
||||
resources_per_worker={"neuron_cores": 1},
|
||||
)
|
||||
e.start()
|
||||
_start_training(e, get_resources)
|
||||
results = e.finish_training()
|
||||
results.sort()
|
||||
assert results == expected_results
|
||||
|
||||
|
||||
def get_node_id_set() -> Set[str]:
|
||||
return {a.node_id for a in list_actors()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [3, 4, 5])
|
||||
def test_placement_group_pack(ray_4_node_4_cpu, num_workers):
|
||||
"""Tests that workers are packed on nodes."""
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=num_workers)
|
||||
e.start()
|
||||
node_id_set = get_node_id_set()
|
||||
assert len(node_id_set) == math.ceil(num_workers / 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [3, 4, 5])
|
||||
def test_placement_group_spread(ray_4_node_4_cpu, num_workers):
|
||||
"""Tests that workers are spread across nodes."""
|
||||
os.environ[TRAIN_ENABLE_WORKER_SPREAD_ENV] = "1"
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=num_workers)
|
||||
e.start()
|
||||
node_id_set = get_node_id_set()
|
||||
assert len(node_id_set) == min(num_workers, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("placement_group_capture_child_tasks", [True, False])
|
||||
def test_placement_group_parent(ray_4_node_4_cpu, placement_group_capture_child_tasks):
|
||||
"""Tests that parent placement group will be used."""
|
||||
num_workers = 2
|
||||
bundle = {"CPU": 1}
|
||||
bundles = [bundle.copy() for _ in range(num_workers + 1)]
|
||||
placement_group = ray.util.placement_group(bundles)
|
||||
|
||||
def train_func():
|
||||
return get_current_placement_group().id
|
||||
|
||||
@ray.remote
|
||||
def test():
|
||||
config = TestConfig()
|
||||
e = BackendExecutor(config, num_workers=2)
|
||||
e.start()
|
||||
_start_training(e, train_func)
|
||||
return e.finish_training()
|
||||
|
||||
results_future = test.options(
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||||
placement_group=placement_group,
|
||||
placement_group_capture_child_tasks=placement_group_capture_child_tasks,
|
||||
),
|
||||
).remote()
|
||||
results = ray.get(results_future)
|
||||
for worker_result in results:
|
||||
if placement_group_capture_child_tasks:
|
||||
assert worker_result == placement_group.id
|
||||
else:
|
||||
assert worker_result != placement_group.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout_s", [5, 0])
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 12),
|
||||
reason="Current jax version is not supported in python 3.12+",
|
||||
)
|
||||
def test_jax_distributed_shutdown_timeout(ray_start_2_cpus, monkeypatch, timeout_s):
|
||||
"""Test that JAX distributed shutdown respects the timeout env var."""
|
||||
monkeypatch.setenv(JAX_DISTRIBUTED_SHUTDOWN_TIMEOUT_S, str(timeout_s))
|
||||
jax_config = JaxConfig(use_tpu=True)
|
||||
e = BackendExecutor(jax_config, num_workers=2)
|
||||
e.start()
|
||||
|
||||
_start_training(e, lambda: 1)
|
||||
assert e.finish_training() == [1, 1]
|
||||
|
||||
# Verify that we do not raise an exception even if we time out
|
||||
e._backend.on_shutdown(e.worker_group, e._backend_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,197 @@
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray.data.context import DataContext
|
||||
from ray.train import Checkpoint, ScalingConfig
|
||||
from ray.train._internal.session import get_session
|
||||
from ray.train.base_trainer import format_datasets_for_repr
|
||||
from ray.train.trainer import BaseTrainer
|
||||
from ray.util.placement_group import get_current_placement_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummyTrainer(BaseTrainer):
|
||||
_scaling_config_allowed_keys = BaseTrainer._scaling_config_allowed_keys + [
|
||||
"num_workers",
|
||||
"use_gpu",
|
||||
"resources_per_worker",
|
||||
"placement_strategy",
|
||||
]
|
||||
|
||||
def __init__(self, train_loop, custom_arg=None, **kwargs):
|
||||
self.custom_arg = custom_arg
|
||||
self.train_loop = train_loop
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def training_loop(self) -> None:
|
||||
self.train_loop(self)
|
||||
|
||||
|
||||
def test_trainer_fit(ray_start_4_cpus):
|
||||
def training_loop(self):
|
||||
train.report(dict(my_metric=1))
|
||||
|
||||
trainer = DummyTrainer(train_loop=training_loop)
|
||||
result = trainer.fit()
|
||||
assert result.metrics["my_metric"] == 1
|
||||
|
||||
|
||||
def test_validate_datasets(ray_start_4_cpus):
|
||||
with pytest.raises(ValueError) as e:
|
||||
DummyTrainer(train_loop=None, datasets=1)
|
||||
assert "`datasets` should be a dict mapping" in str(e.value)
|
||||
|
||||
with pytest.raises(ValueError) as e:
|
||||
DummyTrainer(train_loop=None, datasets={"train": 1})
|
||||
assert "The Dataset under train key is not a `ray.data.Dataset`"
|
||||
|
||||
|
||||
def test_resources(ray_start_4_cpus):
|
||||
def check_cpus(self):
|
||||
assert ray.available_resources()["CPU"] == 2
|
||||
|
||||
assert ray.available_resources()["CPU"] == 4
|
||||
trainer = DummyTrainer(
|
||||
check_cpus,
|
||||
scaling_config=ScalingConfig(
|
||||
trainer_resources={"CPU": 2}, resources_per_worker={}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_arg_override(ray_start_4_cpus):
|
||||
def check_override(self):
|
||||
assert self.scaling_config.num_workers == 1
|
||||
# Should do deep update.
|
||||
assert not self.custom_arg["outer"]["inner"]
|
||||
assert self.custom_arg["outer"]["fixed"] == 1
|
||||
|
||||
pg = get_current_placement_group()
|
||||
assert len(pg.bundle_specs) == 1 # Merged trainer and worker bundle
|
||||
|
||||
scale_config = ScalingConfig(num_workers=4)
|
||||
trainer = DummyTrainer(
|
||||
check_override,
|
||||
custom_arg={"outer": {"inner": True, "fixed": 1}},
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
|
||||
new_config = {
|
||||
"custom_arg": {"outer": {"inner": False}},
|
||||
"scaling_config": ScalingConfig(num_workers=1),
|
||||
}
|
||||
|
||||
tune.run(trainer.as_trainable(), config=new_config)
|
||||
|
||||
|
||||
def test_reserved_cpu_warnings_no_cpu_usage(ray_start_1_cpu_1_gpu):
|
||||
"""Ensure there is no divide by zero error if trial requires no CPUs."""
|
||||
|
||||
def train_loop(config):
|
||||
pass
|
||||
|
||||
trainer = DummyTrainer(
|
||||
train_loop,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=1, use_gpu=True, trainer_resources={"CPU": 0}
|
||||
),
|
||||
datasets={"train": ray.data.range(10)},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_setup(ray_start_4_cpus):
|
||||
def check_setup(self):
|
||||
assert self._has_setup
|
||||
|
||||
class DummyTrainerWithSetup(DummyTrainer):
|
||||
def setup(self):
|
||||
self._has_setup = True
|
||||
|
||||
trainer = DummyTrainerWithSetup(check_setup)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_repr(ray_start_4_cpus):
|
||||
def training_loop(self):
|
||||
pass
|
||||
|
||||
trainer = DummyTrainer(
|
||||
training_loop,
|
||||
datasets={
|
||||
"train": ray.data.from_items([1, 2, 3]),
|
||||
},
|
||||
)
|
||||
|
||||
representation = repr(trainer)
|
||||
|
||||
assert "DummyTrainer" in representation
|
||||
|
||||
|
||||
def test_metadata_propagation(ray_start_4_cpus):
|
||||
class MyTrainer(BaseTrainer):
|
||||
def training_loop(self):
|
||||
assert get_session().metadata == {"a": 1, "b": 1}
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
checkpoint = Checkpoint.from_directory(path)
|
||||
checkpoint.set_metadata({"b": 2, "c": 3})
|
||||
train.report(dict(my_metric=1), checkpoint=checkpoint)
|
||||
|
||||
trainer = MyTrainer(metadata={"a": 1, "b": 1})
|
||||
result = trainer.fit()
|
||||
meta_out = result.checkpoint.get_metadata()
|
||||
assert meta_out == {"a": 1, "b": 2, "c": 3}, meta_out
|
||||
|
||||
|
||||
def test_data_context_propagation(ray_start_4_cpus):
|
||||
ctx = DataContext.get_current()
|
||||
# Fake DataContext attribute to propagate to worker.
|
||||
ctx.foo = "bar"
|
||||
|
||||
def training_loop(self):
|
||||
# Dummy train loop that checks that changes in the driver's
|
||||
# DataContext are propagated to the worker.
|
||||
ctx_worker = DataContext.get_current()
|
||||
assert ctx_worker.foo == "bar"
|
||||
|
||||
trainer = DummyTrainer(
|
||||
train_loop=training_loop,
|
||||
datasets={"train": ray.data.range(10)},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_large_params(ray_start_4_cpus):
|
||||
"""Tests that large params are not serialized with the trainer actor
|
||||
and are instead put into the object store separately."""
|
||||
huge_array = np.zeros(shape=int(1e8))
|
||||
|
||||
def training_loop(self):
|
||||
_ = huge_array
|
||||
|
||||
trainer = DummyTrainer(training_loop)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_format_datasets_for_repr(ray_start_4_cpus):
|
||||
datasets = {"train": ray.data.range(1), "test": ray.data.range(1)}
|
||||
|
||||
actual_repr = format_datasets_for_repr(datasets)
|
||||
|
||||
assert actual_repr == (
|
||||
"{'train': Dataset(num_rows=1, schema={id: int64}), "
|
||||
"'test': Dataset(num_rows=1, schema={id: int64})}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(sys.argv[1:] + ["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for BaseWorkerGroup implementation and usage."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train._internal.base_worker_group import BaseWorkerGroup
|
||||
from ray.train._internal.worker_group import WorkerGroup as V1WorkerGroup
|
||||
from ray.train.v2._internal.execution.worker_group.worker_group import (
|
||||
WorkerGroup as V2WorkerGroup,
|
||||
)
|
||||
|
||||
|
||||
def test_interface_abstract_methods():
|
||||
"""Test that BaseWorkerGroup enforces its abstract methods."""
|
||||
# Should not be able to instantiate interface directly
|
||||
with pytest.raises(TypeError):
|
||||
BaseWorkerGroup()
|
||||
|
||||
# Should not be able to create incomplete implementation
|
||||
class IncompleteWorkerGroup(BaseWorkerGroup):
|
||||
def execute(self, func, *args, **kwargs):
|
||||
pass
|
||||
|
||||
# Missing other abstract methods
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteWorkerGroup()
|
||||
|
||||
|
||||
def test_real_implementations_inherit_interface():
|
||||
"""Smoke test that real WorkerGroup implementations inherit from interface."""
|
||||
# Test inheritance
|
||||
assert issubclass(V1WorkerGroup, BaseWorkerGroup)
|
||||
assert issubclass(V2WorkerGroup, BaseWorkerGroup)
|
||||
|
||||
# Test that all abstract methods are implemented
|
||||
# If any abstract methods are missing, __abstractmethods__ will be non-empty
|
||||
assert (
|
||||
len(V1WorkerGroup.__abstractmethods__) == 0
|
||||
), f"V1 WorkerGroup missing abstract methods: {V1WorkerGroup.__abstractmethods__}"
|
||||
assert (
|
||||
len(V2WorkerGroup.__abstractmethods__) == 0
|
||||
), f"V2 WorkerGroup missing abstract methods: {V2WorkerGroup.__abstractmethods__}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,232 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.train._checkpoint import (
|
||||
_CHECKPOINT_TEMP_DIR_PREFIX,
|
||||
_METADATA_FILE_NAME,
|
||||
Checkpoint,
|
||||
_get_del_lock_path,
|
||||
_list_existing_del_locks,
|
||||
)
|
||||
from ray.train._internal.storage import _exists_at_fs_path, _upload_to_fs_path
|
||||
from ray.train.tests.test_new_persistence import _create_mock_custom_fs
|
||||
|
||||
_CHECKPOINT_CONTENT_FILE = "dummy.txt"
|
||||
|
||||
|
||||
@pytest.fixture(params=["local", "mock", "custom_fs"])
|
||||
def checkpoint(request, tmp_path):
|
||||
"""Fixture that sets up a checkpoint on different filesystems."""
|
||||
checkpoint_fs_type = request.param
|
||||
|
||||
checkpoint_path = tmp_path / "ckpt_dir"
|
||||
checkpoint_path.mkdir(exist_ok=True)
|
||||
(checkpoint_path / _CHECKPOINT_CONTENT_FILE).write_text("dummy")
|
||||
|
||||
if checkpoint_fs_type == "local":
|
||||
yield Checkpoint.from_directory(str(checkpoint_path))
|
||||
elif checkpoint_fs_type == "mock":
|
||||
_checkpoint = Checkpoint(path="mock:///mock_bucket/ckpt_dir")
|
||||
_upload_to_fs_path(
|
||||
local_path=str(checkpoint_path),
|
||||
fs=_checkpoint.filesystem,
|
||||
fs_path=_checkpoint.path,
|
||||
)
|
||||
# The "mock://" URI doesn't persist across different instances of
|
||||
# the pyarrow.fs.MockFileSystem, so we must make sure to return
|
||||
# the checkpoint with the same filesystem instance that we uploaded
|
||||
# some mock content to.
|
||||
yield _checkpoint
|
||||
elif checkpoint_fs_type == "custom_fs":
|
||||
custom_storage_fs = _create_mock_custom_fs(tmp_path / "custom_fs")
|
||||
_upload_to_fs_path(
|
||||
local_path=str(checkpoint_path),
|
||||
fs=custom_storage_fs,
|
||||
fs_path="mock_bucket/ckpt_dir",
|
||||
)
|
||||
yield Checkpoint(path="mock_bucket/ckpt_dir", filesystem=custom_storage_fs)
|
||||
|
||||
|
||||
def test_to_directory(checkpoint: Checkpoint):
|
||||
checkpoint_path = Path(checkpoint.to_directory())
|
||||
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert _CHECKPOINT_TEMP_DIR_PREFIX in checkpoint_path.name
|
||||
|
||||
|
||||
def test_to_directory_with_user_specified_path(checkpoint: Checkpoint, tmp_path):
|
||||
# Test with a string
|
||||
checkpoint_path = Path(checkpoint.to_directory(str(tmp_path / "special_dir")))
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert checkpoint_path.name == "special_dir"
|
||||
|
||||
# Test with a PathLike
|
||||
checkpoint_path = Path(checkpoint.to_directory(tmp_path / "special_dir"))
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
assert checkpoint_path.name == "special_dir"
|
||||
|
||||
|
||||
def test_multiprocess_to_directory(checkpoint: Checkpoint):
|
||||
"""Test the case where multiple processes are trying to checkpoint.
|
||||
|
||||
Only one process should download the checkpoint, and the others should
|
||||
wait until it's finished. In the end, a single checkpoint dir should be
|
||||
shared by all processes.
|
||||
"""
|
||||
if checkpoint.filesystem.type_name == "mock":
|
||||
pytest.skip("Mock filesystem cannot be pickled for use with Ray.")
|
||||
|
||||
@ray.remote
|
||||
def download_checkpoint(checkpoint: Checkpoint) -> str:
|
||||
return checkpoint.to_directory()
|
||||
|
||||
paths = [ray.get(download_checkpoint.remote(checkpoint)) for _ in range(5)]
|
||||
# Check that all the paths are the same (no duplicates).
|
||||
assert len(set(paths)) == 1
|
||||
|
||||
|
||||
def test_as_directory(checkpoint: Checkpoint):
|
||||
with checkpoint.as_directory() as checkpoint_path:
|
||||
checkpoint_path = Path(checkpoint_path)
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
# We should have directly returned the local path
|
||||
assert str(checkpoint_path) == checkpoint.path
|
||||
else:
|
||||
# We should have downloaded to a temp dir.
|
||||
assert _CHECKPOINT_TEMP_DIR_PREFIX in checkpoint_path.name
|
||||
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
# Checkpoint should not be deleted, if we directly gave the local path.
|
||||
assert (checkpoint_path / _CHECKPOINT_CONTENT_FILE).exists()
|
||||
else:
|
||||
# Should have been deleted, if the checkpoint downloaded to a temp dir.
|
||||
assert not checkpoint_path.exists()
|
||||
|
||||
|
||||
def test_multiprocess_as_directory(checkpoint: Checkpoint, monkeypatch):
|
||||
"""Tests that deletion lock files are created and deleted correctly,
|
||||
when multiple processes are accessing a checkpoint with `as_directory`"""
|
||||
# If this is pointing to a local path, no lockfiles will be created.
|
||||
is_local_checkpoint = isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem)
|
||||
|
||||
monkeypatch.setattr(os, "getpid", lambda: 11111)
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir_1:
|
||||
lock_file_1 = Path(_get_del_lock_path(checkpoint_dir_1))
|
||||
|
||||
# Pretend that the 2nd `as_directory` is called from a different process.
|
||||
monkeypatch.setattr(os, "getpid", lambda: 22222)
|
||||
|
||||
with checkpoint.as_directory() as checkpoint_dir_2:
|
||||
lock_file_2 = Path(_get_del_lock_path(checkpoint_dir_2))
|
||||
|
||||
# Should point both processes to the same canonical temp directory.
|
||||
assert checkpoint_dir_1 == checkpoint_dir_2
|
||||
|
||||
if is_local_checkpoint:
|
||||
# Check that 2 different lock files were created.
|
||||
assert not lock_file_1.exists()
|
||||
assert not lock_file_2.exists()
|
||||
else:
|
||||
# Check that 2 different lock files were created.
|
||||
assert lock_file_1.exists()
|
||||
assert lock_file_2.exists()
|
||||
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 2
|
||||
|
||||
if not is_local_checkpoint:
|
||||
# Check that the 2nd lock file was deleted.
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 1
|
||||
assert not lock_file_2.exists()
|
||||
|
||||
if not is_local_checkpoint:
|
||||
# Check that the 1st lock file was deleted.
|
||||
assert len(_list_existing_del_locks(checkpoint_dir_1)) == 0
|
||||
assert not lock_file_1.exists()
|
||||
|
||||
# Check that the temp checkpoint directory was deleted.
|
||||
assert not Path(checkpoint_dir_1).exists()
|
||||
|
||||
|
||||
def test_as_directory_lock_cleanup(checkpoint: Checkpoint):
|
||||
"""Errors when accessing a checkpoint with `as_directory`
|
||||
shouldn't leave behind lock files.
|
||||
"""
|
||||
with pytest.raises(RuntimeError):
|
||||
with checkpoint.as_directory() as checkpoint_dir:
|
||||
raise RuntimeError
|
||||
|
||||
assert not _list_existing_del_locks(checkpoint_dir)
|
||||
|
||||
is_local_checkpoint = isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem)
|
||||
if not is_local_checkpoint:
|
||||
assert not Path(checkpoint_dir).exists()
|
||||
|
||||
|
||||
def test_as_directory_download_error(checkpoint: Checkpoint, monkeypatch):
|
||||
"""Errors during a checkpoint download should be raised directly when accessing
|
||||
it with the `as_directory` context manager."""
|
||||
if isinstance(checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
pytest.skip(
|
||||
"Local filesystem checkpoints don't download to a temp dir, so "
|
||||
"there's no error handling to test."
|
||||
)
|
||||
|
||||
error_text = "original error"
|
||||
|
||||
def to_directory_error(*args, **kwargs):
|
||||
raise RuntimeError(error_text)
|
||||
|
||||
monkeypatch.setattr(checkpoint, "to_directory", to_directory_error)
|
||||
|
||||
with pytest.raises(RuntimeError, match=error_text):
|
||||
with checkpoint.as_directory() as _:
|
||||
pass
|
||||
|
||||
|
||||
def test_metadata(checkpoint: Checkpoint):
|
||||
assert checkpoint.get_metadata() == {}
|
||||
|
||||
# No metadata file by default.
|
||||
assert not _exists_at_fs_path(
|
||||
fs=checkpoint.filesystem,
|
||||
fs_path=str(Path(checkpoint.path) / _METADATA_FILE_NAME),
|
||||
)
|
||||
|
||||
checkpoint.update_metadata({"foo": "bar"})
|
||||
assert checkpoint.get_metadata() == {"foo": "bar"}
|
||||
|
||||
checkpoint.update_metadata({"foo": "baz"})
|
||||
assert checkpoint.get_metadata() == {"foo": "baz"}
|
||||
|
||||
checkpoint.update_metadata({"x": 1})
|
||||
assert checkpoint.get_metadata() == {"foo": "baz", "x": 1}
|
||||
|
||||
# Set metadata completely resets the metadata.
|
||||
checkpoint.set_metadata({"y": [1, 2, 3]})
|
||||
assert checkpoint.get_metadata() == {"y": [1, 2, 3]}
|
||||
|
||||
# There should be a metadata file in the checkpoint directory.
|
||||
assert _exists_at_fs_path(
|
||||
fs=checkpoint.filesystem,
|
||||
fs_path=str(Path(checkpoint.path) / _METADATA_FILE_NAME),
|
||||
)
|
||||
|
||||
# Non JSON serializable metadata should raise an error.
|
||||
class Test:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
checkpoint.set_metadata({"non_json_serializable": Test()})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,239 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train import Checkpoint, CheckpointConfig
|
||||
from ray.train._internal.checkpoint_manager import _CheckpointManager, _TrainingResult
|
||||
from ray.train.constants import TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkpoint_paths(tmp_path):
|
||||
checkpoint_paths = []
|
||||
for i in range(10):
|
||||
checkpoint_path = tmp_path / f"ckpt_{i}"
|
||||
checkpoint_path.mkdir()
|
||||
(checkpoint_path / "dummy.txt").write_text(f"{i}")
|
||||
checkpoint_paths.append(checkpoint_path)
|
||||
|
||||
yield [str(path) for path in checkpoint_paths]
|
||||
|
||||
|
||||
def test_unlimited_checkpoints(checkpoint_paths: List[str]):
|
||||
manager = _CheckpointManager(checkpoint_config=CheckpointConfig(num_to_keep=None))
|
||||
|
||||
for i in range(10):
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 10
|
||||
|
||||
|
||||
def test_limited_checkpoints(checkpoint_paths: List[str]):
|
||||
manager = _CheckpointManager(checkpoint_config=CheckpointConfig(num_to_keep=2))
|
||||
|
||||
for i in range(10):
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 2
|
||||
|
||||
# Keep the latest checkpoints if no metric is given.
|
||||
assert {
|
||||
tracked_checkpoint.metrics["iter"]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
} == {8, 9}
|
||||
|
||||
# The first 8 checkpoints should be deleted.
|
||||
for i in range(8):
|
||||
assert not Path(checkpoint_paths[i]).exists()
|
||||
|
||||
assert Path(checkpoint_paths[8]).exists()
|
||||
assert Path(checkpoint_paths[9]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", ["min", "max"])
|
||||
def test_keep_checkpoints_by_score(order, checkpoint_paths):
|
||||
num_to_keep = 2
|
||||
score_attribute = "score"
|
||||
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=num_to_keep,
|
||||
checkpoint_score_attribute=score_attribute,
|
||||
checkpoint_score_order=order,
|
||||
)
|
||||
)
|
||||
|
||||
scores = []
|
||||
for i in range(10):
|
||||
score = random.random()
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[i]),
|
||||
metrics={"iter": i, score_attribute: score},
|
||||
)
|
||||
)
|
||||
scores.append(score)
|
||||
|
||||
sorted_scores = sorted(scores, reverse=order == "max")
|
||||
assert set(sorted_scores[:num_to_keep]) == {
|
||||
tracked_checkpoint.metrics[score_attribute]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
}
|
||||
|
||||
# Make sure the bottom checkpoints are deleted.
|
||||
best_checkpoint_iters = {
|
||||
tracked_checkpoint.metrics["iter"]
|
||||
for tracked_checkpoint in manager.best_checkpoint_results
|
||||
}
|
||||
for i, checkpoint_path in enumerate(checkpoint_paths):
|
||||
if i in best_checkpoint_iters or i == 9:
|
||||
# The checkpoint should only exist if it's one of the top K or the latest.
|
||||
assert Path(checkpoint_path).exists()
|
||||
else:
|
||||
assert not Path(checkpoint_path).exists()
|
||||
|
||||
|
||||
def test_keep_latest_checkpoint(checkpoint_paths):
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=2,
|
||||
checkpoint_score_attribute="score",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
)
|
||||
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[0]),
|
||||
metrics={"score": 3.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[1]),
|
||||
metrics={"score": 2.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[2]),
|
||||
metrics={"score": 1.0},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(manager.best_checkpoint_results) == 2
|
||||
|
||||
# The latest checkpoint with the lowest score should not be deleted yet.
|
||||
assert manager.latest_checkpoint_result.metrics["score"] == 1.0
|
||||
|
||||
# The latest checkpoint with the lowest score should not be deleted yet.
|
||||
assert Path(checkpoint_paths[2]).exists()
|
||||
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[3]),
|
||||
metrics={"score": 0.0},
|
||||
)
|
||||
)
|
||||
# A newer checkpoint came in. Even though the new one has a lower score, there are
|
||||
# already num_to_keep better checkpoints, so the previous one should be deleted.
|
||||
assert not Path(checkpoint_paths[2]).exists()
|
||||
|
||||
# Quick sanity check to make sure that the new checkpoint is kept.
|
||||
assert manager.latest_checkpoint_result.metrics["score"] == 0.0
|
||||
assert Path(checkpoint_paths[3]).exists()
|
||||
|
||||
# The original 2 checkpoints should still exist
|
||||
assert Path(checkpoint_paths[0]).exists()
|
||||
assert Path(checkpoint_paths[1]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metrics",
|
||||
[
|
||||
{"nested": {"sub": {"attr": 5}}},
|
||||
{"nested": {"sub/attr": 5}},
|
||||
{"nested/sub": {"attr": 5}},
|
||||
{"nested/sub/attr": 5},
|
||||
],
|
||||
)
|
||||
def test_nested_get_checkpoint_score(metrics):
|
||||
manager = _CheckpointManager(
|
||||
checkpoint_config=CheckpointConfig(
|
||||
num_to_keep=2,
|
||||
checkpoint_score_attribute="nested/sub/attr",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
)
|
||||
|
||||
tracked_checkpoint = _TrainingResult(checkpoint=None, metrics=metrics)
|
||||
assert manager._get_checkpoint_score(tracked_checkpoint) == (True, 5.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_score_attr", [True, False])
|
||||
def test_only_store_score_attr(has_score_attr, checkpoint_paths, monkeypatch):
|
||||
monkeypatch.setenv(TUNE_ONLY_STORE_CHECKPOINT_SCORE_ATTRIBUTE, "1")
|
||||
|
||||
# Set up CheckpointManager.
|
||||
if has_score_attr:
|
||||
checkpoint_config = CheckpointConfig(
|
||||
num_to_keep=None,
|
||||
checkpoint_score_attribute="score",
|
||||
checkpoint_score_order="max",
|
||||
)
|
||||
else:
|
||||
checkpoint_config = CheckpointConfig(num_to_keep=None)
|
||||
manager = _CheckpointManager(checkpoint_config=checkpoint_config)
|
||||
|
||||
# Ensure we insert TrainingResults with score in the right order.
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[0]),
|
||||
metrics={"score": 3.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[1]),
|
||||
metrics={"score": 1.0, "another_unsaved_metric": 6.0},
|
||||
)
|
||||
)
|
||||
manager.register_checkpoint(
|
||||
_TrainingResult(
|
||||
checkpoint=Checkpoint.from_directory(checkpoint_paths[2]),
|
||||
metrics={"another_unsaved_metric": 1.0},
|
||||
)
|
||||
)
|
||||
assert len(manager.best_checkpoint_results) == 3
|
||||
if has_score_attr:
|
||||
assert manager.best_checkpoint_results[0].metrics == {"score": 1.0}
|
||||
assert manager.best_checkpoint_results[0].checkpoint.path == checkpoint_paths[1]
|
||||
assert manager.best_checkpoint_results[1].metrics == {"score": 3.0}
|
||||
assert manager.best_checkpoint_results[1].checkpoint.path == checkpoint_paths[0]
|
||||
assert manager.best_checkpoint_results[2].metrics == {}
|
||||
assert manager.best_checkpoint_results[2].checkpoint.path == checkpoint_paths[2]
|
||||
else:
|
||||
assert manager.best_checkpoint_results[0].metrics == {}
|
||||
assert manager.best_checkpoint_results[0].checkpoint.path == checkpoint_paths[0]
|
||||
assert manager.best_checkpoint_results[1].metrics == {}
|
||||
assert manager.best_checkpoint_results[1].checkpoint.path == checkpoint_paths[1]
|
||||
assert manager.best_checkpoint_results[2].metrics == {}
|
||||
assert manager.best_checkpoint_results[2].checkpoint.path == checkpoint_paths[2]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,372 @@
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray._common.utils import RESOURCE_CONSTRAINT_PREFIX
|
||||
from ray.train import RunConfig, ScalingConfig
|
||||
from ray.train._internal.backend_executor import BackendExecutor
|
||||
from ray.train._internal.worker_group import WorkerGroup
|
||||
from ray.train.backend import Backend, BackendConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.utils import _in_ray_train_worker
|
||||
from ray.tune.callback import Callback
|
||||
from ray.tune.tune_config import TuneConfig
|
||||
from ray.tune.tuner import Tuner
|
||||
from ray.util.accelerators import NVIDIA_A100
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_4_gpus_4_extra():
|
||||
address_info = ray.init(num_cpus=4, num_gpus=4, resources={"extra": 4})
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus_4_gpus_4_a100():
|
||||
address_info = ray.init(
|
||||
num_cpus=4,
|
||||
num_gpus=4,
|
||||
resources={f"{RESOURCE_CONSTRAINT_PREFIX}{NVIDIA_A100}": 4},
|
||||
)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def gen_execute_single_async_special(special_f):
|
||||
def execute_single_async_special(self, i, f, *args, **kwargs):
|
||||
assert len(self.workers) == 2
|
||||
if i == 0 and hasattr(self, "should_fail") and self.should_fail:
|
||||
kwargs["train_func"] = special_f
|
||||
return (
|
||||
self.workers[i]
|
||||
.actor._RayTrainWorker__execute.options(name=f.__name__)
|
||||
.remote(f, *args, **kwargs)
|
||||
)
|
||||
|
||||
return execute_single_async_special
|
||||
|
||||
|
||||
def gen_new_backend_executor(special_f):
|
||||
"""Returns a BackendExecutor that runs special_f on worker 0 once."""
|
||||
|
||||
class TestBackendExecutor(BackendExecutor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._has_failed = False
|
||||
|
||||
def start_training(self, *args, **kwargs):
|
||||
special_execute = gen_execute_single_async_special(special_f)
|
||||
if not self._has_failed:
|
||||
self.worker_group.should_fail = True
|
||||
self._has_failed = True
|
||||
else:
|
||||
self.worker_group.should_fail = False
|
||||
with patch.object(WorkerGroup, "execute_single_async", special_execute):
|
||||
super().start_training(*args, **kwargs)
|
||||
|
||||
return TestBackendExecutor
|
||||
|
||||
|
||||
class CaptureReportCallback(Callback):
|
||||
def __init__(self):
|
||||
self.result_list = []
|
||||
|
||||
def on_trial_result(self, iteration, trials, trial, result, **info):
|
||||
self.result_list.append(result)
|
||||
|
||||
|
||||
scale_config = ScalingConfig(num_workers=2)
|
||||
|
||||
|
||||
def test_fit_train(ray_start_4_cpus):
|
||||
def train_func():
|
||||
train.report({"loss": 1})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=scale_config
|
||||
)
|
||||
assert trainer.fit().metrics["loss"] == 1
|
||||
|
||||
|
||||
def test_scaling_config(ray_start_4_cpus):
|
||||
def train_func():
|
||||
assert ray.available_resources()["CPU"] == 1
|
||||
train.report({"loss": 1})
|
||||
|
||||
assert ray.available_resources()["CPU"] == 4
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func, scaling_config=ScalingConfig(num_workers=2)
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_fit_train_config(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
train.report({"loss": config["x"]})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
scaling_config=scale_config,
|
||||
train_loop_config={"x": 100},
|
||||
)
|
||||
assert trainer.fit().metrics["loss"] == 100
|
||||
|
||||
|
||||
def test_datasets(ray_start_4_cpus):
|
||||
num_train_data = 10
|
||||
num_val_data = 6
|
||||
|
||||
train_dataset = ray.data.range(num_train_data)
|
||||
val_dataset = ray.data.range(num_val_data)
|
||||
|
||||
def get_dataset():
|
||||
# Train dataset should be sharded.
|
||||
train_dataset = train.get_dataset_shard("train")
|
||||
train_ds_count = len(list(train_dataset.iter_rows()))
|
||||
assert train_ds_count == num_train_data / scale_config.num_workers
|
||||
# All other datasets should not be sharded.
|
||||
val_dataset = train.get_dataset_shard("val")
|
||||
val_ds_count = len(list(val_dataset.iter_rows()))
|
||||
assert val_ds_count == num_val_data / scale_config.num_workers
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=get_dataset,
|
||||
scaling_config=scale_config,
|
||||
datasets={"train": train_dataset, "val": val_dataset},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_invalid_train_loop():
|
||||
def train_loop(config, extra_arg):
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DataParallelTrainer(train_loop_per_worker=train_loop)
|
||||
|
||||
|
||||
def test_bad_return_in_train_loop(ray_start_4_cpus):
|
||||
"""Test to check if returns from train loop are discarded."""
|
||||
|
||||
# Simulates what happens with eg. torch models
|
||||
class FailOnUnpickle:
|
||||
def __reduce__(self):
|
||||
raise RuntimeError("Failing")
|
||||
|
||||
def train_loop(config):
|
||||
train.report({"loss": 1})
|
||||
return FailOnUnpickle()
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_loop, scaling_config=scale_config
|
||||
)
|
||||
|
||||
# No exception should happen here
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_tune(ray_start_4_cpus):
|
||||
def train_func(config):
|
||||
train.report({"loss": config["x"]})
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config={"x": 100},
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
|
||||
tuner = Tuner(
|
||||
trainer,
|
||||
param_space={"train_loop_config": {"x": tune.choice([200, 300])}},
|
||||
tune_config=TuneConfig(num_samples=2),
|
||||
)
|
||||
result_grid = tuner.fit()
|
||||
assert result_grid[0].metrics["loss"] in [200, 300]
|
||||
|
||||
# Make sure original Trainer is not affected.
|
||||
assert trainer._train_loop_config["x"] == 100
|
||||
|
||||
|
||||
def test_fast_slow(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report(dict(index=i), checkpoint=checkpoint)
|
||||
|
||||
def train_slow():
|
||||
for i in range(2):
|
||||
with create_dict_checkpoint({"epoch": i}) as checkpoint:
|
||||
train.report(dict(index=i), checkpoint=checkpoint)
|
||||
time.sleep(5)
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_slow)
|
||||
callback = CaptureReportCallback()
|
||||
|
||||
class DataParallelTrainerPatched(DataParallelTrainer):
|
||||
_backend_executor_cls = new_backend_executor_cls
|
||||
|
||||
trainer = DataParallelTrainerPatched(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
run_config=RunConfig(callbacks=[callback]),
|
||||
)
|
||||
results = trainer.fit()
|
||||
|
||||
assert load_dict_checkpoint(results.checkpoint)["epoch"] == 1
|
||||
|
||||
result_list = callback.result_list
|
||||
assert len(result_list) == 2
|
||||
|
||||
|
||||
def test_mismatch_report(ray_start_4_cpus):
|
||||
def train_func():
|
||||
for _ in range(2):
|
||||
train.report(dict(loss=1))
|
||||
|
||||
def train_mismatch():
|
||||
train.report(dict(loss=1))
|
||||
|
||||
new_backend_executor_cls = gen_new_backend_executor(train_mismatch)
|
||||
|
||||
class DataParallelTrainerPatched(DataParallelTrainer):
|
||||
_backend_executor_cls = new_backend_executor_cls
|
||||
|
||||
trainer = DataParallelTrainerPatched(
|
||||
train_func,
|
||||
scaling_config=scale_config,
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_world_rank(ray_start_4_cpus, tmp_path):
|
||||
def train_func():
|
||||
world_rank = train.get_context().get_world_rank()
|
||||
(tmp_path / f"{world_rank}").touch()
|
||||
train.report(dict(world_rank=world_rank))
|
||||
|
||||
trainer = DataParallelTrainer(train_func, scaling_config=scale_config)
|
||||
trainer.fit()
|
||||
|
||||
created_files = list(tmp_path.glob("*"))
|
||||
assert len(created_files) == 2
|
||||
assert {int(file.name) for file in created_files} == {0, 1}
|
||||
|
||||
|
||||
def test_gpu_requests(ray_start_4_cpus_4_gpus_4_extra, tmp_path):
|
||||
def get_visible_devices_for_workers():
|
||||
return [file.read_text() for file in tmp_path.glob("*")]
|
||||
|
||||
class CudaTestBackend(Backend):
|
||||
share_cuda_visible_devices = True
|
||||
|
||||
class CudaTestConfig(BackendConfig):
|
||||
@property
|
||||
def backend_cls(self):
|
||||
return CudaTestBackend
|
||||
|
||||
def get_resources():
|
||||
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
||||
world_rank = train.get_context().get_world_rank()
|
||||
(tmp_path / f"{world_rank}").write_text(cuda_visible_devices)
|
||||
train.report(dict(devices=cuda_visible_devices))
|
||||
|
||||
# 0 GPUs will be requested and should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=False),
|
||||
)
|
||||
trainer.fit()
|
||||
assert get_visible_devices_for_workers() == ["", ""]
|
||||
|
||||
# 1 GPU will be requested and should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(num_workers=2, use_gpu=True),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
visible_devices = [",".join(sorted(r.split(","))) for r in visible_devices]
|
||||
assert visible_devices == ["0,1", "0,1"]
|
||||
|
||||
# Partial GPUs should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2, use_gpu=True, resources_per_worker={"GPU": 0.1}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
assert visible_devices == ["0", "0"]
|
||||
|
||||
# Multiple GPUs should not raise an error.
|
||||
trainer = DataParallelTrainer(
|
||||
get_resources,
|
||||
backend_config=CudaTestConfig(),
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=2, use_gpu=True, resources_per_worker={"GPU": 2}
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
visible_devices = get_visible_devices_for_workers()
|
||||
# Sort the cuda visible devices to have exact match with expected result.
|
||||
visible_devices = [",".join(sorted(r.split(","))) for r in visible_devices]
|
||||
assert visible_devices == ["0,1,2,3", "0,1,2,3"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accelerator_type", [NVIDIA_A100, None])
|
||||
def test_config_accelerator_type(ray_start_4_cpus_4_gpus_4_a100, accelerator_type):
|
||||
def train_func():
|
||||
# Ensure all workers are scheduled on nodes with specified accelerators
|
||||
assigned_resources = ray.get_runtime_context().get_assigned_resources()
|
||||
if accelerator_type:
|
||||
accelerator_key = f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"
|
||||
assert accelerator_key in assigned_resources
|
||||
|
||||
trainer = DataParallelTrainer(
|
||||
train_func,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=4,
|
||||
use_gpu=True,
|
||||
accelerator_type=accelerator_type,
|
||||
),
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_in_ray_train_worker(ray_start_4_cpus):
|
||||
assert not _in_ray_train_worker()
|
||||
|
||||
def train_fn():
|
||||
assert _in_ray_train_worker()
|
||||
|
||||
trainer = DataParallelTrainer(train_fn)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user