chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
load("@rules_python//python:defs.bzl", "py_library", "py_test")
|
||||
load("//bazel:python.bzl", "doctest")
|
||||
|
||||
doctest(
|
||||
name = "py_doctest[air]",
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
files = glob(
|
||||
["**/*.py"],
|
||||
exclude = glob([
|
||||
"examples/**/*",
|
||||
"tests/**/*",
|
||||
"callbacks/*.py",
|
||||
]) + ["integrations/wandb.py"],
|
||||
),
|
||||
tags = ["team:ml"],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "conftest",
|
||||
srcs = ["tests/conftest.py"],
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Tests from the python/ray/air/tests directory.
|
||||
# Covers all tests starting with `test_`.
|
||||
# Please keep these sorted alphabetically.
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
py_test(
|
||||
name = "test_air_usage",
|
||||
size = "small",
|
||||
srcs = ["tests/test_air_usage.py"],
|
||||
# NOTE: This tests Train V1 telemetry.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_new_dataset_config",
|
||||
size = "large",
|
||||
srcs = ["tests/test_new_dataset_config.py"],
|
||||
# NOTE: Relevant tests moved to train/v2/tests/test_data_integration.py
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_experiment_restore",
|
||||
size = "large",
|
||||
srcs = [
|
||||
"tests/_test_experiment_restore_run.py",
|
||||
"tests/test_experiment_restore.py",
|
||||
],
|
||||
# NOTE: This tests Tune and Train V1 restoration.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_errors",
|
||||
size = "medium",
|
||||
srcs = ["tests/test_errors.py"],
|
||||
# NOTE: This tests Tune (Train V1) error propagation logic.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_integration_comet",
|
||||
size = "small",
|
||||
srcs = ["tests/test_integration_comet.py"],
|
||||
# NOTE: This tests the Tune Comet callback.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_integration_wandb",
|
||||
size = "small",
|
||||
srcs = ["tests/test_integration_wandb.py"],
|
||||
# NOTE: This tests the Tune wandb callback.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "0"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_integration_mlflow",
|
||||
size = "medium",
|
||||
srcs = ["tests/test_integration_mlflow.py"],
|
||||
# NOTE: This tests the Tune mlflow callback.
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_keras_callback",
|
||||
size = "medium",
|
||||
srcs = ["tests/test_keras_callback.py"],
|
||||
env = {
|
||||
"RAY_TRAIN_V2_ENABLED": "1",
|
||||
"TF_USE_LEGACY_KERAS": "1",
|
||||
},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_remote_storage_hdfs",
|
||||
size = "small",
|
||||
srcs = ["tests/test_remote_storage_hdfs.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"hdfs",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_tracebacks",
|
||||
size = "small",
|
||||
srcs = ["tests/test_tracebacks.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_utils",
|
||||
size = "small",
|
||||
srcs = ["tests/test_utils.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Tests from the python/ray/air/tests/execution directory.
|
||||
# Covers all tests starting with `test_`.
|
||||
# Please keep these sorted alphabetically.
|
||||
# TODO: Move this to Tune.
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
py_test(
|
||||
name = "test_barrier",
|
||||
size = "small",
|
||||
srcs = ["tests/execution/test_barrier.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_e2e_train_flow",
|
||||
size = "medium",
|
||||
srcs = ["tests/execution/test_e2e_train_flow.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_e2e_tune_flow",
|
||||
size = "medium",
|
||||
srcs = ["tests/execution/test_e2e_tune_flow.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_event_manager",
|
||||
size = "medium",
|
||||
srcs = ["tests/execution/test_event_manager.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_resource_manager_fixed",
|
||||
size = "small",
|
||||
srcs = ["tests/execution/test_resource_manager_fixed.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_resource_manager_placement_group",
|
||||
size = "medium",
|
||||
srcs = ["tests/execution/test_resource_manager_placement_group.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_resource_request",
|
||||
size = "small",
|
||||
srcs = ["tests/execution/test_resource_request.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_tracked_actor",
|
||||
size = "small",
|
||||
srcs = ["tests/execution/test_tracked_actor.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "test_tracked_actor_task",
|
||||
size = "small",
|
||||
srcs = ["tests/execution/test_tracked_actor_task.py"],
|
||||
env = {"RAY_TRAIN_V2_ENABLED": "1"},
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:ml",
|
||||
],
|
||||
deps = [":ml_lib"],
|
||||
)
|
||||
|
||||
# This is a dummy test dependency that causes the above tests to be
|
||||
# re-run if any of these files changes.
|
||||
py_library(
|
||||
name = "ml_lib",
|
||||
srcs = glob(
|
||||
["**/*.py"],
|
||||
exclude = ["tests/*.py"],
|
||||
),
|
||||
visibility = [
|
||||
"//python/ray/air:__pkg__",
|
||||
"//python/ray/air:__subpackages__",
|
||||
"//python/ray/train:__pkg__",
|
||||
"//python/ray/train:__subpackages__",
|
||||
"//release:__pkg__",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
from ray.air.config import (
|
||||
CheckpointConfig,
|
||||
FailureConfig,
|
||||
RunConfig,
|
||||
ScalingConfig,
|
||||
)
|
||||
from ray.air.data_batch_type import DataBatchType
|
||||
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
|
||||
from ray.air.result import Result
|
||||
import ray.data # noqa: F401 # TODO: This is a hack to avoid circular import
|
||||
|
||||
__all__ = [
|
||||
"DataBatchType",
|
||||
"RunConfig",
|
||||
"Result",
|
||||
"ScalingConfig",
|
||||
"FailureConfig",
|
||||
"CheckpointConfig",
|
||||
"AcquiredResources",
|
||||
"ResourceRequest",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
import dataclasses
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def _validate_allowed_keys_exist(
|
||||
dataclass_name: str, data_dict: dict, allowed_keys: set
|
||||
):
|
||||
keys_not_in_dict = allowed_keys.difference(data_dict)
|
||||
if keys_not_in_dict:
|
||||
raise ValueError(
|
||||
f"Key(s) {sorted(keys_not_in_dict)} are not present in {dataclass_name}. "
|
||||
"Remove them from `allowed_keys`. "
|
||||
f"Valid keys: {sorted(data_dict.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def ensure_only_allowed_dataclass_keys_updated(
|
||||
dataclass: dataclasses.dataclass,
|
||||
allowed_keys: Iterable[str],
|
||||
):
|
||||
"""
|
||||
Validate dataclass by raising an exception if any key not included in
|
||||
``allowed_keys`` differs from the default value.
|
||||
|
||||
A ``ValueError`` will also be raised if any of the ``allowed_keys``
|
||||
is not present in ``dataclass.__dict__``.
|
||||
|
||||
Args:
|
||||
dataclass: Dict or dataclass to check.
|
||||
allowed_keys: dataclass attribute keys that can have a value different than
|
||||
the default one.
|
||||
"""
|
||||
default_data = dataclass.__class__()
|
||||
default_data_dict = default_data.__dict__
|
||||
|
||||
allowed_keys = set(allowed_keys)
|
||||
_validate_allowed_keys_exist(
|
||||
dataclass.__class__.__name__, default_data_dict, allowed_keys
|
||||
)
|
||||
|
||||
# These keys should not have been updated in the `dataclass` object
|
||||
prohibited_keys = set(default_data_dict) - allowed_keys
|
||||
dataclass_dict = dataclass.__dict__
|
||||
|
||||
bad_keys = [
|
||||
key for key in prohibited_keys if dataclass_dict[key] != default_data_dict[key]
|
||||
]
|
||||
if bad_keys:
|
||||
raise ValueError(
|
||||
f"Key(s) {bad_keys} are not allowed to be updated in the current context. "
|
||||
"Remove them from the dataclass."
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray.air._internal.device_manager.cpu import CPUTorchDeviceManager
|
||||
from ray.air._internal.device_manager.hpu import HPUTorchDeviceManager
|
||||
from ray.air._internal.device_manager.npu import NPUTorchDeviceManager
|
||||
from ray.air._internal.device_manager.nvidia_gpu import CUDATorchDeviceManager
|
||||
from ray.air._internal.device_manager.torch_device_manager import TorchDeviceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_TORCH_DEVICE_MANAGER_CLS = CPUTorchDeviceManager
|
||||
|
||||
|
||||
SUPPORTED_ACCELERATOR_TORCH_DEVICE_MANAGER = {
|
||||
ray_constants.GPU: CUDATorchDeviceManager,
|
||||
ray_constants.HPU: HPUTorchDeviceManager,
|
||||
ray_constants.NPU: NPUTorchDeviceManager,
|
||||
}
|
||||
|
||||
|
||||
def register_custom_torch_dist_backend(backend: Optional[str] = None) -> None:
|
||||
if backend == "hccl":
|
||||
# The name for the communication backend of Habana and torch-npu is the same.
|
||||
HPUTorchDeviceManager.register_custom_torch_dist_backend()
|
||||
|
||||
NPUTorchDeviceManager.register_custom_torch_dist_backend()
|
||||
|
||||
|
||||
_torch_device_manager = None
|
||||
_torch_device_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_torch_device_manager_by_context() -> TorchDeviceManager:
|
||||
global _torch_device_manager
|
||||
|
||||
with _torch_device_manager_lock:
|
||||
if not _torch_device_manager:
|
||||
existing_device_manager_cls = None
|
||||
resources = ray.get_runtime_context().get_accelerator_ids()
|
||||
|
||||
# select correct accelerator type from resources
|
||||
for resource_type, resource_value in resources.items():
|
||||
device_manager_cls = SUPPORTED_ACCELERATOR_TORCH_DEVICE_MANAGER.get(
|
||||
resource_type, None
|
||||
)
|
||||
if resource_value and device_manager_cls:
|
||||
# An error will raise when multiple accelerators are specified.
|
||||
if existing_device_manager_cls:
|
||||
raise RuntimeError(
|
||||
"Unable to determine the appropriate DeviceManager "
|
||||
f"for the specified resources {resources}."
|
||||
)
|
||||
else:
|
||||
existing_device_manager_cls = device_manager_cls
|
||||
|
||||
device_manager_cls = (
|
||||
existing_device_manager_cls or DEFAULT_TORCH_DEVICE_MANAGER_CLS
|
||||
)
|
||||
|
||||
_torch_device_manager = device_manager_cls()
|
||||
|
||||
return _torch_device_manager
|
||||
|
||||
|
||||
def get_torch_device_manager_by_device_type(device_type: str):
|
||||
if device_type.lower() == ray_constants.GPU.lower() or device_type == "cuda":
|
||||
return CUDATorchDeviceManager()
|
||||
elif device_type.lower() == ray_constants.NPU.lower():
|
||||
return NPUTorchDeviceManager()
|
||||
elif device_type.lower() == ray_constants.HPU.lower():
|
||||
return HPUTorchDeviceManager()
|
||||
elif device_type.lower() == "cpu":
|
||||
return CPUTorchDeviceManager()
|
||||
|
||||
raise RuntimeError(f"Device type {device_type} cannot be recognized.")
|
||||
|
||||
|
||||
__all__ = [
|
||||
TorchDeviceManager,
|
||||
CPUTorchDeviceManager,
|
||||
CUDATorchDeviceManager,
|
||||
HPUTorchDeviceManager,
|
||||
NPUTorchDeviceManager,
|
||||
register_custom_torch_dist_backend,
|
||||
get_torch_device_manager_by_context,
|
||||
get_torch_device_manager_by_device_type,
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import List
|
||||
|
||||
import torch
|
||||
|
||||
from ray.air._internal.device_manager.torch_device_manager import TorchDeviceManager
|
||||
|
||||
|
||||
class CPUTorchDeviceManager(TorchDeviceManager):
|
||||
"""CPU device manager"""
|
||||
|
||||
def is_available(self) -> bool():
|
||||
return True
|
||||
|
||||
def get_devices(self) -> List[torch.device]:
|
||||
"""Gets the correct torch device list configured for this process."""
|
||||
return [torch.device("cpu")]
|
||||
|
||||
def supports_stream(self) -> bool:
|
||||
"""Validate if the device type support create a stream"""
|
||||
return False
|
||||
|
||||
def get_stream_context(self, stream):
|
||||
"""Return empty context mananger for CPU."""
|
||||
|
||||
@contextmanager
|
||||
def default_context_manager():
|
||||
yield
|
||||
|
||||
return default_context_manager()
|
||||
@@ -0,0 +1,50 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import List, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ray._private.accelerators.hpu import HPU_PACKAGE_AVAILABLE
|
||||
from ray.air._internal.device_manager.torch_device_manager import TorchDeviceManager
|
||||
|
||||
if HPU_PACKAGE_AVAILABLE:
|
||||
import habana_frameworks.torch.hpu as torch_hpu
|
||||
|
||||
|
||||
class HPUTorchDeviceManager(TorchDeviceManager):
|
||||
"""HPU device manager"""
|
||||
|
||||
@staticmethod
|
||||
def register_custom_torch_dist_backend():
|
||||
if HPU_PACKAGE_AVAILABLE:
|
||||
import habana_frameworks.torch.core # noqa: F401
|
||||
import habana_frameworks.torch.distributed.hccl # noqa: F401
|
||||
|
||||
def is_available(self) -> bool():
|
||||
if not HPU_PACKAGE_AVAILABLE:
|
||||
return False
|
||||
|
||||
return torch_hpu.is_available()
|
||||
|
||||
def get_devices(self) -> List[torch.device]:
|
||||
if not self.is_available():
|
||||
raise RuntimeError(
|
||||
"Using HPUTorchDeviceManager but torch hpu is not available."
|
||||
)
|
||||
|
||||
return [torch.device("hpu")]
|
||||
|
||||
def set_device(self, device: Union[torch.device, int, str, None]):
|
||||
torch_hpu.set_device(device)
|
||||
|
||||
def supports_stream(self) -> bool:
|
||||
"""Validate if the device type support create a stream"""
|
||||
return False
|
||||
|
||||
def get_stream_context(self, stream):
|
||||
"""Get HPU stream context manager, empty so far."""
|
||||
|
||||
@contextmanager
|
||||
def default_context_manager():
|
||||
yield
|
||||
|
||||
return default_context_manager()
|
||||
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
from importlib.util import find_spec
|
||||
from typing import List, Union
|
||||
|
||||
import torch
|
||||
|
||||
import ray
|
||||
import ray._private.ray_constants as ray_constants
|
||||
from ray._private.accelerators.npu import ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
|
||||
from ray.air._internal.device_manager.torch_device_manager import TorchDeviceManager
|
||||
|
||||
|
||||
def is_package_present(package_name: str) -> bool:
|
||||
try:
|
||||
return find_spec(package_name) is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
NPU_TORCH_PACKAGE_AVAILABLE = is_package_present("torch_npu")
|
||||
|
||||
|
||||
if NPU_TORCH_PACKAGE_AVAILABLE:
|
||||
import torch_npu # noqa: F401
|
||||
|
||||
|
||||
class NPUTorchDeviceManager(TorchDeviceManager):
|
||||
"""Ascend NPU device manager"""
|
||||
|
||||
@staticmethod
|
||||
def register_custom_torch_dist_backend():
|
||||
if NPU_TORCH_PACKAGE_AVAILABLE:
|
||||
import torch_npu # noqa: F401, F811
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not NPU_TORCH_PACKAGE_AVAILABLE:
|
||||
return False
|
||||
|
||||
return torch.npu.is_available()
|
||||
|
||||
def get_devices(self) -> List[torch.device]:
|
||||
"""Gets the correct torch device list configured for this process.
|
||||
|
||||
Returns a list of torch NPU devices allocated for the current worker.
|
||||
If no NPUs are assigned, then it returns a list with a single CPU device.
|
||||
"""
|
||||
if NPU_TORCH_PACKAGE_AVAILABLE and torch.npu.is_available():
|
||||
npu_ids = [
|
||||
str(id)
|
||||
for id in ray.get_runtime_context().get_accelerator_ids()[
|
||||
ray_constants.NPU
|
||||
]
|
||||
]
|
||||
|
||||
device_ids = []
|
||||
|
||||
if len(npu_ids) > 0:
|
||||
npu_visible_str = os.environ.get(ASCEND_RT_VISIBLE_DEVICES_ENV_VAR, "")
|
||||
if npu_visible_str and npu_visible_str != "NoDevFiles":
|
||||
npu_visible_list = npu_visible_str.split(",")
|
||||
else:
|
||||
npu_visible_list = []
|
||||
|
||||
for npu_id in npu_ids:
|
||||
try:
|
||||
device_ids.append(npu_visible_list.index(npu_id))
|
||||
except IndexError:
|
||||
raise RuntimeError(
|
||||
"ASCEND_RT_VISIBLE_DEVICES set incorrectly. "
|
||||
f"Got {npu_visible_str}, expected to include {npu_id}. "
|
||||
"Did you override the `ASCEND_RT_VISIBLE_DEVICES` "
|
||||
"environment variable?"
|
||||
)
|
||||
else:
|
||||
# If called on the driver or outside of Ray Train, return the
|
||||
# 0th device.
|
||||
device_ids.append(0)
|
||||
|
||||
devices = [torch.device(f"npu:{device_id}") for device_id in device_ids]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Using NPUTorchDeviceManager but torch npu is not available."
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
def set_device(self, device: Union[torch.device, int]):
|
||||
torch.npu.set_device(device)
|
||||
|
||||
def supports_stream(self) -> bool:
|
||||
"""Validate if the device type support to create a stream"""
|
||||
return True
|
||||
|
||||
def create_stream(self, device):
|
||||
"""Create a stream on NPU device"""
|
||||
return torch.npu.Stream(device)
|
||||
|
||||
def get_stream_context(self, stream):
|
||||
"""Get a torch.stream context on NPU device"""
|
||||
return torch.npu.stream(stream)
|
||||
|
||||
def get_current_stream(self):
|
||||
"""Get current stream for NPU device"""
|
||||
return torch.npu.current_stream()
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
from typing import List, Union
|
||||
|
||||
import torch
|
||||
|
||||
import ray
|
||||
from ray.air._internal.device_manager.torch_device_manager import TorchDeviceManager
|
||||
|
||||
|
||||
class CUDATorchDeviceManager(TorchDeviceManager):
|
||||
"""CUDA device manager"""
|
||||
|
||||
def is_available(self) -> bool():
|
||||
return torch.cuda.is_available()
|
||||
|
||||
def get_devices(self) -> List[torch.device]:
|
||||
"""Gets the correct torch device list configured for this process.
|
||||
|
||||
Returns a list of torch CUDA devices allocated for the current worker.
|
||||
If no GPUs are assigned, then it returns a list with a single CPU device.
|
||||
|
||||
Assumes that `CUDA_VISIBLE_DEVICES` is set and is a
|
||||
superset of the `ray.get_gpu_ids()`.
|
||||
"""
|
||||
|
||||
# GPU IDs are assigned by Ray after you specify "use_gpu"
|
||||
# GPU `ray.get_gpu_ids()` may return ints or may return strings.
|
||||
# We should always convert to strings.
|
||||
gpu_ids = [str(id) for id in ray.get_gpu_ids()]
|
||||
|
||||
device_ids = []
|
||||
|
||||
if len(gpu_ids) > 0:
|
||||
cuda_visible_str = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
||||
if cuda_visible_str and cuda_visible_str != "NoDevFiles":
|
||||
cuda_visible_list = cuda_visible_str.split(",")
|
||||
else:
|
||||
cuda_visible_list = []
|
||||
|
||||
# By default, there should only be one GPU ID if `use_gpu=True`.
|
||||
# If there are multiple GPUs, return a list of devices.
|
||||
# If using fractional GPUs, these IDs are not guaranteed
|
||||
# to be unique across different processes.
|
||||
for gpu_id in gpu_ids:
|
||||
try:
|
||||
device_ids.append(cuda_visible_list.index(gpu_id))
|
||||
except IndexError:
|
||||
raise RuntimeError(
|
||||
"CUDA_VISIBLE_DEVICES set incorrectly. "
|
||||
f"Got {cuda_visible_str}, expected to include {gpu_id}. "
|
||||
"Did you override the `CUDA_VISIBLE_DEVICES` environment"
|
||||
" variable? If not, please help file an issue on Github."
|
||||
)
|
||||
|
||||
else:
|
||||
# If called on the driver or outside of Ray Train, return the
|
||||
# 0th device.
|
||||
device_ids.append(0)
|
||||
|
||||
return [torch.device(f"cuda:{device_id}") for device_id in device_ids]
|
||||
|
||||
def set_device(self, device: Union[torch.device, int, str, None]):
|
||||
torch.cuda.set_device(device)
|
||||
|
||||
def supports_stream(self) -> bool:
|
||||
"""Validate if the device type support create a stream"""
|
||||
return True
|
||||
|
||||
def create_stream(self, device: torch.device) -> torch.cuda.Stream:
|
||||
"""Create a stream on cuda device"""
|
||||
return torch.cuda.Stream(device)
|
||||
|
||||
def get_stream_context(self, stream):
|
||||
"""Get a stream context for cuda device"""
|
||||
return torch.cuda.stream(stream)
|
||||
|
||||
def get_current_stream(self) -> torch.cuda.Stream:
|
||||
"""Get current stream for cuda device"""
|
||||
return torch.cuda.current_stream()
|
||||
@@ -0,0 +1,40 @@
|
||||
from abc import ABC
|
||||
from typing import List, Union
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
class TorchDeviceManager(ABC):
|
||||
"""This class contains the function needed for supporting
|
||||
an acclerator family in Ray AI Library.
|
||||
"""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Validate if device is available."""
|
||||
...
|
||||
|
||||
def get_devices(self) -> List[torch.device]:
|
||||
"""Gets the correct torch device configured for this process"""
|
||||
...
|
||||
|
||||
def set_device(self, device: Union[torch.device, int, str, None]):
|
||||
"""Set the correct device for this process"""
|
||||
...
|
||||
|
||||
def supports_stream(self) -> bool:
|
||||
"""Validate if the device type support create a stream"""
|
||||
...
|
||||
|
||||
def create_stream(self, device: torch.device):
|
||||
"""Create a device stream"""
|
||||
...
|
||||
|
||||
def get_stream_context(self, stream):
|
||||
"""Get a stream context of device. If device didn't support stream,
|
||||
this should return a empty context manager instead of None.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_current_stream(self):
|
||||
"""Get current stream on accelerators like torch.cuda.current_stream"""
|
||||
...
|
||||
@@ -0,0 +1,46 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
import ray
|
||||
|
||||
RAY_LOCKFILE_DIR = "_ray_lockfiles"
|
||||
|
||||
|
||||
class TempFileLock:
|
||||
"""FileLock wrapper that uses temporary file locks.
|
||||
|
||||
The temporary directory that these locks are saved to can be configured via
|
||||
the `RAY_TMPDIR` environment variable.
|
||||
|
||||
Args:
|
||||
path: The file path that this temporary file lock is used for.
|
||||
This will be used to generate the lockfile filename.
|
||||
Ex: For concurrent writes to a file, this is the common filepath
|
||||
that multiple processes are writing to.
|
||||
**kwargs: Additional keyword arguments to pass to the underlying `FileLock`.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, **kwargs):
|
||||
self.path = path
|
||||
temp_dir = Path(ray._common.utils.get_default_system_temp_dir()).resolve()
|
||||
self._lock_dir = temp_dir / RAY_LOCKFILE_DIR
|
||||
self._path_hash = hashlib.sha256(
|
||||
str(Path(self.path).resolve()).encode("utf-8")
|
||||
).hexdigest()
|
||||
self._lock_path = self._lock_dir / f"{self._path_hash}.lock"
|
||||
|
||||
os.makedirs(str(self._lock_dir), exist_ok=True)
|
||||
self._lock = FileLock(self._lock_path, **kwargs)
|
||||
|
||||
def __enter__(self):
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
self._lock.release()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._lock, name)
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
import numbers
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class SafeFallbackEncoder(json.JSONEncoder):
|
||||
def __init__(self, nan_str="null", **kwargs):
|
||||
super(SafeFallbackEncoder, self).__init__(**kwargs)
|
||||
self.nan_str = nan_str
|
||||
|
||||
def default(self, value):
|
||||
try:
|
||||
if type(value).__module__ == np.__name__ and isinstance(value, np.ndarray):
|
||||
return value.tolist()
|
||||
|
||||
if isinstance(value, np.bool_):
|
||||
return bool(value)
|
||||
|
||||
if np.isnan(value):
|
||||
return self.nan_str
|
||||
|
||||
if issubclass(type(value), numbers.Integral):
|
||||
return int(value)
|
||||
if issubclass(type(value), numbers.Number):
|
||||
return float(value)
|
||||
|
||||
return super(SafeFallbackEncoder, self).default(value)
|
||||
|
||||
except Exception:
|
||||
return str(value) # give up, just stringify it (ok for logs)
|
||||
@@ -0,0 +1,346 @@
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
|
||||
from packaging import version
|
||||
|
||||
from ray._private.dict import flatten_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mlflow.entities import Run
|
||||
from mlflow.tracking import MlflowClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _MLflowLoggerUtil:
|
||||
"""Util class for setting up and logging to MLflow.
|
||||
|
||||
Use this util for any library that needs MLflow logging/tracking logic
|
||||
such as Ray Tune or Ray Train.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
import mlflow
|
||||
|
||||
self._mlflow = mlflow
|
||||
self.experiment_id = None
|
||||
|
||||
def __deepcopy__(self, memo=None):
|
||||
# mlflow is a module, and thus cannot be copied
|
||||
_mlflow = self._mlflow
|
||||
self.__dict__.pop("_mlflow")
|
||||
dict_copy = deepcopy(self.__dict__, memo)
|
||||
copied_object = _MLflowLoggerUtil()
|
||||
copied_object.__dict__.update(dict_copy)
|
||||
self._mlflow = _mlflow
|
||||
copied_object._mlflow = _mlflow
|
||||
return copied_object
|
||||
|
||||
def setup_mlflow(
|
||||
self,
|
||||
tracking_uri: Optional[str] = None,
|
||||
registry_uri: Optional[str] = None,
|
||||
experiment_id: Optional[str] = None,
|
||||
experiment_name: Optional[str] = None,
|
||||
tracking_token: Optional[str] = None,
|
||||
artifact_location: Optional[str] = None,
|
||||
create_experiment_if_not_exists: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Sets up MLflow.
|
||||
|
||||
Sets the Mlflow tracking uri & token, and registry URI. Also sets
|
||||
the MLflow experiment that the logger should use, and possibly
|
||||
creates new experiment if it does not exist.
|
||||
|
||||
Args:
|
||||
tracking_uri: The tracking URI for the MLflow tracking
|
||||
server.
|
||||
registry_uri: The registry URI for the MLflow model registry.
|
||||
experiment_id: The id of an already existing MLflow
|
||||
experiment to use for logging. If None is passed in
|
||||
here and the MFLOW_EXPERIMENT_ID is not set, or the
|
||||
experiment with this id does not exist,
|
||||
``experiment_name`` will be used instead. This argument takes
|
||||
precedence over ``experiment_name`` if both are passed in.
|
||||
experiment_name: The experiment name to use for logging.
|
||||
If None is passed in here, the MLFLOW_EXPERIMENT_NAME environment
|
||||
variable is used to determine the experiment name.
|
||||
If the experiment with the name already exists with MLflow,
|
||||
it will be reused. If not, a new experiment will be created
|
||||
with the provided name if
|
||||
``create_experiment_if_not_exists`` is set to True.
|
||||
tracking_token: Tracking token used to authenticate with MLflow.
|
||||
artifact_location: The location to store run artifacts.
|
||||
If not provided, MLFlow picks an appropriate default.
|
||||
Ignored if experiment already exists.
|
||||
create_experiment_if_not_exists: Whether to create an
|
||||
experiment with the provided name if it does not already
|
||||
exist. Defaults to True.
|
||||
|
||||
Raises:
|
||||
ValueError: ``experiment_id`` and ``experiment_name`` are both ``None``.
|
||||
"""
|
||||
if tracking_token:
|
||||
os.environ["MLFLOW_TRACKING_TOKEN"] = tracking_token
|
||||
|
||||
self._mlflow.set_tracking_uri(tracking_uri)
|
||||
self._mlflow.set_registry_uri(registry_uri)
|
||||
|
||||
# First check experiment_id.
|
||||
experiment_id = (
|
||||
experiment_id
|
||||
if experiment_id is not None
|
||||
else os.environ.get("MLFLOW_EXPERIMENT_ID")
|
||||
)
|
||||
if experiment_id is not None:
|
||||
from mlflow.exceptions import MlflowException
|
||||
|
||||
try:
|
||||
self._mlflow.get_experiment(experiment_id=experiment_id)
|
||||
logger.debug(
|
||||
f"Experiment with provided id {experiment_id} "
|
||||
"exists. Setting that as the experiment."
|
||||
)
|
||||
self.experiment_id = experiment_id
|
||||
return
|
||||
except MlflowException:
|
||||
pass
|
||||
|
||||
# Then check experiment_name.
|
||||
experiment_name = (
|
||||
experiment_name
|
||||
if experiment_name is not None
|
||||
else os.environ.get("MLFLOW_EXPERIMENT_NAME")
|
||||
)
|
||||
if experiment_name is not None and self._mlflow.get_experiment_by_name(
|
||||
name=experiment_name
|
||||
):
|
||||
logger.debug(
|
||||
f"Experiment with provided name {experiment_name} "
|
||||
"exists. Setting that as the experiment."
|
||||
)
|
||||
self.experiment_id = self._mlflow.get_experiment_by_name(
|
||||
experiment_name
|
||||
).experiment_id
|
||||
return
|
||||
|
||||
# An experiment with the provided id or name does not exist.
|
||||
# Create a new experiment if applicable.
|
||||
if experiment_name and create_experiment_if_not_exists:
|
||||
logger.debug(
|
||||
"Existing experiment not found. Creating new "
|
||||
f"experiment with name: {experiment_name}"
|
||||
)
|
||||
self.experiment_id = self._mlflow.create_experiment(
|
||||
name=experiment_name, artifact_location=artifact_location
|
||||
)
|
||||
return
|
||||
|
||||
if create_experiment_if_not_exists:
|
||||
raise ValueError(
|
||||
f"Experiment with the provided experiment_id: "
|
||||
f"{experiment_id} does not exist and no "
|
||||
f"experiment_name provided. At least one of "
|
||||
f"these has to be provided."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Experiment with the provided experiment_id: "
|
||||
f"{experiment_id} or experiment_name: "
|
||||
f"{experiment_name} does not exist. Please "
|
||||
f"create an MLflow experiment and provide "
|
||||
f"either its id or name."
|
||||
)
|
||||
|
||||
def _parse_dict(self, dict_to_log: Dict) -> Dict:
|
||||
"""Parses provided dict to convert all values to float.
|
||||
|
||||
MLflow can only log metrics that are floats. This does not apply to
|
||||
logging parameters or artifacts.
|
||||
|
||||
Args:
|
||||
dict_to_log: The dictionary containing the metrics to log.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the metrics to log with all values being
|
||||
converted to floats, or skipped if not able to be converted.
|
||||
"""
|
||||
new_dict = {}
|
||||
for key, value in dict_to_log.items():
|
||||
try:
|
||||
value = float(value)
|
||||
new_dict[key] = value
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Cannot log key {} with value {} since the "
|
||||
"value cannot be converted to float.".format(key, value)
|
||||
)
|
||||
continue
|
||||
|
||||
return new_dict
|
||||
|
||||
def start_run(
|
||||
self,
|
||||
run_name: Optional[str] = None,
|
||||
tags: Optional[Dict] = None,
|
||||
set_active: bool = False,
|
||||
) -> "Run":
|
||||
"""Starts a new run and possibly sets it as the active run.
|
||||
|
||||
Args:
|
||||
run_name: Name of the new MLflow run to create.
|
||||
tags: Tags to set for the new run.
|
||||
set_active: Whether to set the new run as the active run.
|
||||
If an active run already exists, then that run is returned.
|
||||
|
||||
Returns:
|
||||
The newly created MLflow run.
|
||||
"""
|
||||
import mlflow
|
||||
from mlflow.utils.mlflow_tags import MLFLOW_RUN_NAME
|
||||
|
||||
if tags is None:
|
||||
tags = {}
|
||||
|
||||
if set_active:
|
||||
return self._start_active_run(run_name=run_name, tags=tags)
|
||||
|
||||
client = self._get_client()
|
||||
# If `mlflow==1.30.0` and we don't use `run_name`, then MLflow might error. For
|
||||
# more information, see #29749.
|
||||
if version.parse(mlflow.__version__) >= version.parse("1.30.0"):
|
||||
run = client.create_run(
|
||||
run_name=run_name, experiment_id=self.experiment_id, tags=tags
|
||||
)
|
||||
else:
|
||||
tags[MLFLOW_RUN_NAME] = run_name
|
||||
run = client.create_run(experiment_id=self.experiment_id, tags=tags)
|
||||
|
||||
return run
|
||||
|
||||
def _start_active_run(
|
||||
self, run_name: Optional[str] = None, tags: Optional[Dict] = None
|
||||
) -> "Run":
|
||||
"""Starts a run and sets it as the active run if one does not exist.
|
||||
|
||||
If an active run already exists, then returns it.
|
||||
"""
|
||||
active_run = self._mlflow.active_run()
|
||||
if active_run:
|
||||
return active_run
|
||||
|
||||
return self._mlflow.start_run(
|
||||
run_name=run_name, experiment_id=self.experiment_id, tags=tags
|
||||
)
|
||||
|
||||
def _run_exists(self, run_id: str) -> bool:
|
||||
"""Check if run with the provided id exists."""
|
||||
from mlflow.exceptions import MlflowException
|
||||
|
||||
try:
|
||||
self._mlflow.get_run(run_id=run_id)
|
||||
return True
|
||||
except MlflowException:
|
||||
return False
|
||||
|
||||
def _get_client(self) -> "MlflowClient":
|
||||
"""Returns an ml.tracking.MlflowClient instance to use for logging."""
|
||||
tracking_uri = self._mlflow.get_tracking_uri()
|
||||
registry_uri = self._mlflow.get_registry_uri()
|
||||
|
||||
from mlflow.tracking import MlflowClient
|
||||
|
||||
return MlflowClient(tracking_uri=tracking_uri, registry_uri=registry_uri)
|
||||
|
||||
def log_params(self, params_to_log: Dict, run_id: Optional[str] = None):
|
||||
"""Logs the provided parameters to the run specified by run_id.
|
||||
|
||||
If no ``run_id`` is passed in, then logs to the current active run.
|
||||
If there is not active run, then creates a new run and sets it as
|
||||
the active run.
|
||||
|
||||
Args:
|
||||
params_to_log: Dictionary of parameters to log.
|
||||
run_id: The ID of the run to log to.
|
||||
"""
|
||||
params_to_log = flatten_dict(params_to_log)
|
||||
|
||||
if run_id and self._run_exists(run_id):
|
||||
client = self._get_client()
|
||||
for key, value in params_to_log.items():
|
||||
client.log_param(run_id=run_id, key=key, value=value)
|
||||
|
||||
else:
|
||||
for key, value in params_to_log.items():
|
||||
self._mlflow.log_param(key=key, value=value)
|
||||
|
||||
def log_metrics(
|
||||
self, step: int, metrics_to_log: Dict, run_id: Optional[str] = None
|
||||
):
|
||||
"""Logs the provided metrics to the run specified by run_id.
|
||||
|
||||
|
||||
If no ``run_id`` is passed in, then logs to the current active run.
|
||||
If there is not active run, then creates a new run and sets it as
|
||||
the active run.
|
||||
|
||||
Args:
|
||||
step: Step at which the metrics are logged.
|
||||
metrics_to_log: Dictionary of metrics to log.
|
||||
run_id: The ID of the run to log to.
|
||||
"""
|
||||
metrics_to_log = flatten_dict(metrics_to_log)
|
||||
metrics_to_log = self._parse_dict(metrics_to_log)
|
||||
|
||||
if run_id and self._run_exists(run_id):
|
||||
client = self._get_client()
|
||||
for key, value in metrics_to_log.items():
|
||||
client.log_metric(run_id=run_id, key=key, value=value, step=step)
|
||||
|
||||
else:
|
||||
for key, value in metrics_to_log.items():
|
||||
self._mlflow.log_metric(key=key, value=value, step=step)
|
||||
|
||||
def save_artifacts(self, dir: str, run_id: Optional[str] = None):
|
||||
"""Saves directory as artifact to the run specified by run_id.
|
||||
|
||||
If no ``run_id`` is passed in, then saves to the current active run.
|
||||
If there is not active run, then creates a new run and sets it as
|
||||
the active run.
|
||||
|
||||
Args:
|
||||
dir: Path to directory containing the files to save.
|
||||
run_id: The ID of the run to log to.
|
||||
"""
|
||||
if run_id and self._run_exists(run_id):
|
||||
client = self._get_client()
|
||||
client.log_artifacts(run_id=run_id, local_dir=dir)
|
||||
else:
|
||||
self._mlflow.log_artifacts(local_dir=dir)
|
||||
|
||||
def end_run(self, status: Optional[str] = None, run_id: Optional[str] = None):
|
||||
"""Terminates the run specified by run_id.
|
||||
|
||||
If no ``run_id`` is passed in, then terminates the
|
||||
active run if one exists.
|
||||
|
||||
Args:
|
||||
status: The status to set when terminating the run.
|
||||
run_id: The ID of the run to terminate.
|
||||
|
||||
"""
|
||||
if (
|
||||
run_id
|
||||
and self._run_exists(run_id)
|
||||
and not (
|
||||
self._mlflow.active_run()
|
||||
and self._mlflow.active_run().info.run_id == run_id
|
||||
)
|
||||
):
|
||||
client = self._get_client()
|
||||
client.set_terminated(run_id=run_id, status=status)
|
||||
else:
|
||||
self._mlflow.end_run(status=status)
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
|
||||
from ray.air.util.data_batch_conversion import _unwrap_ndarray_object_type_if_needed
|
||||
|
||||
|
||||
def convert_ndarray_to_tf_tensor(
|
||||
ndarray: np.ndarray,
|
||||
dtype: Optional[tf.dtypes.DType] = None,
|
||||
type_spec: Optional[tf.TypeSpec] = None,
|
||||
) -> tf.Tensor:
|
||||
"""Convert a NumPy ndarray to a TensorFlow Tensor.
|
||||
|
||||
Args:
|
||||
ndarray: A NumPy ndarray that we wish to convert to a TensorFlow Tensor.
|
||||
dtype: A TensorFlow dtype for the created tensor; if None, the dtype will be
|
||||
inferred from the NumPy ndarray data.
|
||||
type_spec: A type spec that specifies the shape and dtype of the returned
|
||||
tensor. If you specify ``dtype``, the dtype stored in the type spec is
|
||||
ignored.
|
||||
|
||||
Returns:
|
||||
A TensorFlow Tensor.
|
||||
"""
|
||||
if dtype is None and type_spec is not None:
|
||||
dtype = type_spec.dtype
|
||||
|
||||
is_ragged = isinstance(type_spec, tf.RaggedTensorSpec)
|
||||
ndarray = _unwrap_ndarray_object_type_if_needed(ndarray)
|
||||
if is_ragged:
|
||||
return tf.ragged.constant(ndarray, dtype=dtype)
|
||||
else:
|
||||
return tf.convert_to_tensor(ndarray, dtype=dtype)
|
||||
|
||||
|
||||
def convert_ndarray_batch_to_tf_tensor_batch(
|
||||
ndarrays: Union[np.ndarray, Dict[str, np.ndarray]],
|
||||
dtypes: Optional[Union[tf.dtypes.DType, Dict[str, tf.dtypes.DType]]] = None,
|
||||
) -> Union[tf.Tensor, Dict[str, tf.Tensor]]:
|
||||
"""Convert a NumPy ndarray batch to a TensorFlow Tensor batch.
|
||||
|
||||
Args:
|
||||
ndarrays: A (dict of) NumPy ndarray(s) that we wish to convert to a TensorFlow
|
||||
Tensor.
|
||||
dtypes: A (dict of) TensorFlow dtype(s) for the created tensor; if None, the
|
||||
dtype will be inferred from the NumPy ndarray data.
|
||||
|
||||
Returns:
|
||||
A (dict of) TensorFlow Tensor(s).
|
||||
"""
|
||||
if isinstance(ndarrays, np.ndarray):
|
||||
# Single-tensor case.
|
||||
if isinstance(dtypes, dict):
|
||||
if len(dtypes) != 1:
|
||||
raise ValueError(
|
||||
"When constructing a single-tensor batch, only a single dtype "
|
||||
f"should be given, instead got: {dtypes}"
|
||||
)
|
||||
dtypes = next(iter(dtypes.values()))
|
||||
batch = convert_ndarray_to_tf_tensor(ndarrays, dtypes)
|
||||
else:
|
||||
# Multi-tensor case.
|
||||
batch = {
|
||||
col_name: convert_ndarray_to_tf_tensor(
|
||||
col_ndarray,
|
||||
dtype=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
|
||||
)
|
||||
for col_name, col_ndarray in ndarrays.items()
|
||||
}
|
||||
|
||||
return batch
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ray.air._internal.device_manager import get_torch_device_manager_by_context
|
||||
|
||||
|
||||
def get_devices() -> List[torch.device]:
|
||||
"""Gets the correct torch device list configured for this process.
|
||||
|
||||
Returns a list of torch accelerator (GPU, HPU, NPU...) devices allocated for
|
||||
the current worker.
|
||||
If no accelerators are assigned, then it returns a list with a single CPU device.
|
||||
"""
|
||||
return get_torch_device_manager_by_context().get_devices()
|
||||
|
||||
|
||||
def load_torch_model(
|
||||
saved_model: Union[torch.nn.Module, Dict],
|
||||
model_definition: Optional[torch.nn.Module] = None,
|
||||
) -> torch.nn.Module:
|
||||
"""Loads a PyTorch model from the provided ``saved_model``.
|
||||
|
||||
``model_definition`` is only used when ``saved_model`` is
|
||||
a torch state dict, which will be loaded into ``model_definition``.
|
||||
Otherwise, ``model_definition`` is discarded.
|
||||
"""
|
||||
if isinstance(saved_model, torch.nn.Module):
|
||||
return saved_model
|
||||
elif isinstance(saved_model, dict):
|
||||
if not model_definition:
|
||||
raise ValueError(
|
||||
"Attempting to load torch model from a "
|
||||
"state_dict, but no `model_definition` was "
|
||||
"provided."
|
||||
)
|
||||
model_definition.load_state_dict(saved_model)
|
||||
return model_definition
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Saved model is of type {type(saved_model)}. "
|
||||
f"The model saved in the checkpoint is expected "
|
||||
f"to be of type `torch.nn.Module`, or a model "
|
||||
f"state dict of type dict."
|
||||
)
|
||||
|
||||
|
||||
def contains_tensor(obj):
|
||||
if isinstance(obj, torch.Tensor):
|
||||
return True
|
||||
elif isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if contains_tensor(k):
|
||||
return True
|
||||
if contains_tensor(v):
|
||||
return True
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
for v in obj:
|
||||
if contains_tensor(v):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Not present in torch<=1.7.0
|
||||
# Adapted from https://github.com/pytorch/pytorch/blob/\
|
||||
# c18da597e0bb1c1aecc97c77a73fed1849057fa4/torch/nn/modules/utils.py
|
||||
def consume_prefix_in_state_dict_if_present_not_in_place(
|
||||
state_dict: Dict[str, Any], prefix: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Strip the prefix in state_dict, if any and return a new dict.
|
||||
|
||||
Adapted from https://github.com/pytorch/pytorch/blob/\
|
||||
c18da597e0bb1c1aecc97c77a73fed1849057fa4/torch/nn/modules/utils.py
|
||||
The original method modified the dict in-place.
|
||||
|
||||
Args:
|
||||
state_dict: a state-dict to be loaded to the model.
|
||||
prefix: prefix.
|
||||
|
||||
Returns:
|
||||
A new state-dict with the prefix stripped from the keys.
|
||||
"""
|
||||
copied = False
|
||||
|
||||
for key in state_dict:
|
||||
if key.startswith(prefix):
|
||||
newkey = key[len(prefix) :]
|
||||
if not copied:
|
||||
# We are doing shallow copies here, so the performance
|
||||
# impact should be negligible anyway, but this is
|
||||
# a simple optimization.
|
||||
state_dict = state_dict.copy()
|
||||
copied = True
|
||||
state_dict[newkey] = state_dict.pop(key)
|
||||
|
||||
if "_metadata" in state_dict:
|
||||
state_dict["_metadata"] = state_dict["_metadata"].copy()
|
||||
metadata = state_dict["_metadata"]
|
||||
for key in metadata:
|
||||
if len(key) == 0:
|
||||
continue
|
||||
newkey = key[len(prefix) :]
|
||||
metadata[newkey] = metadata.pop(key)
|
||||
|
||||
return state_dict
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
|
||||
class URI:
|
||||
"""Represents a URI, supporting path appending and retrieving parent URIs.
|
||||
|
||||
Example Usage:
|
||||
|
||||
>>> s3_uri = URI("s3://bucket/a?scheme=http¶m=1")
|
||||
>>> s3_uri
|
||||
URI<s3://bucket/a?scheme=http¶m=1>
|
||||
>>> str(s3_uri / "b" / "c")
|
||||
's3://bucket/a/b/c?scheme=http¶m=1'
|
||||
>>> str(s3_uri.parent)
|
||||
's3://bucket?scheme=http¶m=1'
|
||||
>>> str(s3_uri)
|
||||
's3://bucket/a?scheme=http¶m=1'
|
||||
>>> s3_uri.parent.name, s3_uri.name
|
||||
('bucket', 'a')
|
||||
>>> local_path = URI("/tmp/local")
|
||||
>>> str(local_path)
|
||||
'/tmp/local'
|
||||
>>> str(local_path.parent)
|
||||
'/tmp'
|
||||
>>> str(local_path / "b" / "c")
|
||||
'/tmp/local/b/c'
|
||||
|
||||
Args:
|
||||
uri: The URI to represent.
|
||||
Ex: s3://bucket?scheme=http&endpoint_override=localhost%3A900
|
||||
Ex: file:///a/b/c/d
|
||||
"""
|
||||
|
||||
def __init__(self, uri: str):
|
||||
self._parsed = urllib.parse.urlparse(uri)
|
||||
if not self._parsed.scheme:
|
||||
# Just treat this as a regular path
|
||||
self._path = Path(uri)
|
||||
else:
|
||||
self._path = Path(os.path.normpath(self._parsed.netloc + self._parsed.path))
|
||||
|
||||
def rstrip_subpath(self, subpath: Path) -> "URI":
|
||||
"""Returns a new URI that strips the given subpath from the end of this URI.
|
||||
|
||||
Example:
|
||||
>>> uri = URI("s3://bucket/a/b/c/?param=1")
|
||||
>>> str(uri.rstrip_subpath(Path("b/c")))
|
||||
's3://bucket/a?param=1'
|
||||
|
||||
>>> uri = URI("/tmp/a/b/c/")
|
||||
>>> str(uri.rstrip_subpath(Path("/b/c/.//")))
|
||||
'/tmp/a'
|
||||
|
||||
Args:
|
||||
subpath: The subpath to strip from the end of this URI.
|
||||
|
||||
Returns:
|
||||
A new URI with the subpath stripped from the end.
|
||||
"""
|
||||
assert str(self._path).endswith(str(subpath)), (self._path, subpath)
|
||||
stripped_path = str(self._path).replace(str(subpath), "")
|
||||
return URI(self._get_str_representation(self._parsed, stripped_path))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._path.name
|
||||
|
||||
@property
|
||||
def parent(self) -> "URI":
|
||||
assert self._path.parent != ".", f"{str(self)} has no valid parent URI"
|
||||
return URI(self._get_str_representation(self._parsed, self._path.parent))
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
return self._parsed.scheme
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return str(self._path)
|
||||
|
||||
def __truediv__(self, path_to_append):
|
||||
assert isinstance(path_to_append, str)
|
||||
return URI(
|
||||
self._get_str_representation(self._parsed, self._path / path_to_append)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_str_representation(
|
||||
cls, parsed_uri: urllib.parse.ParseResult, path: Union[str, Path]
|
||||
) -> str:
|
||||
if not parsed_uri.scheme:
|
||||
return str(path)
|
||||
return parsed_uri._replace(netloc=str(path), path="").geturl()
|
||||
|
||||
def __repr__(self):
|
||||
return f"URI<{str(self)}>"
|
||||
|
||||
def __str__(self):
|
||||
return self._get_str_representation(self._parsed, self._path)
|
||||
|
||||
|
||||
def is_uri(path: str) -> bool:
|
||||
return bool(urllib.parse.urlparse(path).scheme)
|
||||
@@ -0,0 +1,277 @@
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
|
||||
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.train.trainer import BaseTrainer
|
||||
from ray.tune import Callback
|
||||
from ray.tune.schedulers import TrialScheduler
|
||||
from ray.tune.search import BasicVariantGenerator, Searcher
|
||||
|
||||
|
||||
AIR_TRAINERS = {
|
||||
"HorovodTrainer",
|
||||
"LightGBMTrainer",
|
||||
"TensorflowTrainer",
|
||||
"TorchTrainer",
|
||||
"XGBoostTrainer",
|
||||
}
|
||||
|
||||
TRAIN_V2_TRAINERS = {
|
||||
"DataParallelTrainer",
|
||||
"JaxTrainer",
|
||||
"LightGBMTrainer",
|
||||
"TensorflowTrainer",
|
||||
"TorchTrainer",
|
||||
"XGBoostTrainer",
|
||||
}
|
||||
|
||||
# searchers implemented by Ray Tune.
|
||||
TUNE_SEARCHERS = {
|
||||
"AxSearch",
|
||||
"BayesOptSearch",
|
||||
"TuneBOHB",
|
||||
"HEBOSearch",
|
||||
"HyperOptSearch",
|
||||
"NevergradSearch",
|
||||
"OptunaSearch",
|
||||
"ZOOptSearch",
|
||||
}
|
||||
|
||||
# These are just wrappers around real searchers.
|
||||
# We don't want to double tag in this case, otherwise, the real tag
|
||||
# will be overwritten.
|
||||
TUNE_SEARCHER_WRAPPERS = {
|
||||
"ConcurrencyLimiter",
|
||||
"Repeater",
|
||||
}
|
||||
|
||||
TUNE_SCHEDULERS = {
|
||||
"FIFOScheduler",
|
||||
"AsyncHyperBandScheduler",
|
||||
"MedianStoppingRule",
|
||||
"HyperBandScheduler",
|
||||
"HyperBandForBOHB",
|
||||
"PopulationBasedTraining",
|
||||
"PopulationBasedTrainingReplay",
|
||||
"PB2",
|
||||
"ResourceChangingScheduler",
|
||||
}
|
||||
|
||||
|
||||
class AirEntrypoint(Enum):
|
||||
TUNER = "Tuner.fit"
|
||||
TRAINER = "Trainer.fit"
|
||||
TUNE_RUN = "tune.run"
|
||||
TUNE_RUN_EXPERIMENTS = "tune.run_experiments"
|
||||
|
||||
|
||||
def _find_class_name(obj: object, allowed_module_path_prefix: str, whitelist: Set[str]):
|
||||
"""Find the class name of the object. If the object is not
|
||||
under `allowed_module_path_prefix` or if its class is not in the whitelist,
|
||||
return "Custom".
|
||||
|
||||
Args:
|
||||
obj: The object under inspection.
|
||||
allowed_module_path_prefix: If the `obj`'s class is not under
|
||||
the `allowed_module_path_prefix`, its class name will be anonymized.
|
||||
whitelist: If the `obj`'s class is not in the `whitelist`,
|
||||
it will be anonymized.
|
||||
Returns:
|
||||
The class name to be tagged with telemetry.
|
||||
"""
|
||||
module_path = obj.__module__
|
||||
cls_name = obj.__class__.__name__
|
||||
if module_path.startswith(allowed_module_path_prefix) and cls_name in whitelist:
|
||||
return cls_name
|
||||
else:
|
||||
return "Custom"
|
||||
|
||||
|
||||
def tag_air_trainer(trainer: "BaseTrainer"):
|
||||
from ray.train.trainer import BaseTrainer
|
||||
|
||||
assert isinstance(trainer, BaseTrainer)
|
||||
trainer_name = _find_class_name(trainer, "ray.train", AIR_TRAINERS)
|
||||
record_extra_usage_tag(TagKey.AIR_TRAINER, trainer_name)
|
||||
|
||||
|
||||
def tag_train_v2_trainer(trainer):
|
||||
from ray.train.v2.api.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
assert isinstance(trainer, DataParallelTrainer)
|
||||
trainer_name = _find_class_name(trainer, "ray.train", TRAIN_V2_TRAINERS)
|
||||
record_extra_usage_tag(TagKey.TRAIN_TRAINER, trainer_name)
|
||||
|
||||
|
||||
def tag_searcher(searcher: Union["BasicVariantGenerator", "Searcher"]):
|
||||
from ray.tune.search import BasicVariantGenerator, Searcher
|
||||
|
||||
if isinstance(searcher, BasicVariantGenerator):
|
||||
# Note this could be highly inflated as all train flows are treated
|
||||
# as using BasicVariantGenerator.
|
||||
record_extra_usage_tag(TagKey.TUNE_SEARCHER, "BasicVariantGenerator")
|
||||
elif isinstance(searcher, Searcher):
|
||||
searcher_name = _find_class_name(
|
||||
searcher, "ray.tune.search", TUNE_SEARCHERS.union(TUNE_SEARCHER_WRAPPERS)
|
||||
)
|
||||
if searcher_name in TUNE_SEARCHER_WRAPPERS:
|
||||
# ignore to avoid double tagging with wrapper name.
|
||||
return
|
||||
record_extra_usage_tag(TagKey.TUNE_SEARCHER, searcher_name)
|
||||
else:
|
||||
assert False, (
|
||||
"Not expecting a non-BasicVariantGenerator, "
|
||||
"non-Searcher type passed in for `tag_searcher`."
|
||||
)
|
||||
|
||||
|
||||
def tag_scheduler(scheduler: "TrialScheduler"):
|
||||
from ray.tune.schedulers import TrialScheduler
|
||||
|
||||
assert isinstance(scheduler, TrialScheduler)
|
||||
scheduler_name = _find_class_name(scheduler, "ray.tune.schedulers", TUNE_SCHEDULERS)
|
||||
record_extra_usage_tag(TagKey.TUNE_SCHEDULER, scheduler_name)
|
||||
|
||||
|
||||
def tag_setup_wandb():
|
||||
record_extra_usage_tag(TagKey.AIR_SETUP_WANDB_INTEGRATION_USED, "1")
|
||||
|
||||
|
||||
def tag_setup_mlflow():
|
||||
record_extra_usage_tag(TagKey.AIR_SETUP_MLFLOW_INTEGRATION_USED, "1")
|
||||
|
||||
|
||||
def _count_callbacks(callbacks: Optional[List["Callback"]]) -> Dict[str, int]:
|
||||
"""Creates a map of callback class name -> count given a list of callbacks."""
|
||||
from ray.air.integrations.comet import CometLoggerCallback
|
||||
from ray.air.integrations.mlflow import MLflowLoggerCallback
|
||||
from ray.air.integrations.wandb import WandbLoggerCallback
|
||||
from ray.tune import Callback
|
||||
from ray.tune.logger import LoggerCallback
|
||||
from ray.tune.logger.aim import AimLoggerCallback
|
||||
from ray.tune.utils.callback import DEFAULT_CALLBACK_CLASSES
|
||||
|
||||
built_in_callbacks = (
|
||||
WandbLoggerCallback,
|
||||
MLflowLoggerCallback,
|
||||
CometLoggerCallback,
|
||||
AimLoggerCallback,
|
||||
) + DEFAULT_CALLBACK_CLASSES
|
||||
|
||||
callback_names = [callback_cls.__name__ for callback_cls in built_in_callbacks]
|
||||
callback_counts = collections.defaultdict(int)
|
||||
|
||||
callbacks = callbacks or []
|
||||
for callback in callbacks:
|
||||
if not isinstance(callback, Callback):
|
||||
# This will error later, but don't include this as custom usage.
|
||||
continue
|
||||
|
||||
callback_name = callback.__class__.__name__
|
||||
|
||||
if callback_name in callback_names:
|
||||
callback_counts[callback_name] += 1
|
||||
elif isinstance(callback, LoggerCallback):
|
||||
callback_counts["CustomLoggerCallback"] += 1
|
||||
else:
|
||||
callback_counts["CustomCallback"] += 1
|
||||
|
||||
return callback_counts
|
||||
|
||||
|
||||
def tag_callbacks(callbacks: Optional[List["Callback"]]) -> bool:
|
||||
"""Records built-in callback usage via a JSON str representing a
|
||||
dictionary mapping callback class name -> counts.
|
||||
|
||||
User-defined callbacks will increment the count under the `CustomLoggerCallback`
|
||||
or `CustomCallback` key depending on which of the provided interfaces they subclass.
|
||||
NOTE: This will NOT track the name of the user-defined callback,
|
||||
nor its implementation.
|
||||
|
||||
This will NOT report telemetry if no callbacks are provided by the user.
|
||||
|
||||
Args:
|
||||
callbacks: List of callbacks supplied by the user. May be ``None``.
|
||||
|
||||
Returns:
|
||||
bool: True if usage was recorded, False otherwise.
|
||||
"""
|
||||
if not callbacks:
|
||||
# User didn't pass in any callbacks -> no usage recorded.
|
||||
return False
|
||||
|
||||
callback_counts = _count_callbacks(callbacks)
|
||||
|
||||
if callback_counts:
|
||||
callback_counts_str = json.dumps(callback_counts)
|
||||
record_extra_usage_tag(TagKey.AIR_CALLBACKS, callback_counts_str)
|
||||
|
||||
|
||||
def tag_storage_type(storage: "StorageContext"):
|
||||
"""Records the storage configuration of an experiment.
|
||||
|
||||
The storage configuration is set by `RunConfig(storage_path, storage_filesystem)`.
|
||||
|
||||
The possible storage types (defined by `pyarrow.fs.FileSystem.type_name`) are:
|
||||
- 'local' = pyarrow.fs.LocalFileSystem. This includes NFS usage.
|
||||
- 'mock' = pyarrow.fs._MockFileSystem. This is used for testing.
|
||||
- ('s3', 'gcs', 'abfs', 'hdfs'): Various remote storage schemes
|
||||
with default implementations in pyarrow.
|
||||
- 'custom' = All other storage schemes, which includes ALL cases where a
|
||||
custom `storage_filesystem` is provided.
|
||||
- 'other' = catches any other cases not explicitly handled above.
|
||||
"""
|
||||
whitelist = {"local", "mock", "s3", "gcs", "abfs", "hdfs"}
|
||||
|
||||
if storage.custom_fs_provided:
|
||||
storage_config_tag = "custom"
|
||||
elif storage.storage_filesystem.type_name in whitelist:
|
||||
storage_config_tag = storage.storage_filesystem.type_name
|
||||
else:
|
||||
storage_config_tag = "other"
|
||||
|
||||
record_extra_usage_tag(TagKey.AIR_STORAGE_CONFIGURATION, storage_config_tag)
|
||||
|
||||
|
||||
def tag_ray_air_env_vars() -> bool:
|
||||
"""Records usage of environment variables exposed by the Ray AIR libraries.
|
||||
|
||||
NOTE: This does not track the values of the environment variables, nor
|
||||
does this track environment variables not explicitly included in the
|
||||
`all_ray_air_env_vars` allow-list.
|
||||
|
||||
Returns:
|
||||
bool: True if at least one environment var is supplied by the user.
|
||||
"""
|
||||
from ray.air.constants import AIR_ENV_VARS
|
||||
from ray.train.constants import TRAIN_ENV_VARS
|
||||
from ray.tune.constants import TUNE_ENV_VARS
|
||||
|
||||
all_ray_air_env_vars = sorted(
|
||||
set().union(AIR_ENV_VARS, TUNE_ENV_VARS, TRAIN_ENV_VARS)
|
||||
)
|
||||
|
||||
user_supplied_env_vars = []
|
||||
|
||||
for env_var in all_ray_air_env_vars:
|
||||
if env_var in os.environ:
|
||||
user_supplied_env_vars.append(env_var)
|
||||
|
||||
if user_supplied_env_vars:
|
||||
env_vars_str = json.dumps(user_supplied_env_vars)
|
||||
record_extra_usage_tag(TagKey.AIR_ENV_VARS, env_vars_str)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def tag_air_entrypoint(entrypoint: AirEntrypoint) -> None:
|
||||
"""Records the entrypoint to an AIR training run."""
|
||||
assert entrypoint in AirEntrypoint
|
||||
record_extra_usage_tag(TagKey.AIR_ENTRYPOINT, entrypoint.value)
|
||||
@@ -0,0 +1,125 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.air.constants import _ERROR_REPORT_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_nan(value):
|
||||
return np.isnan(value)
|
||||
|
||||
|
||||
def is_nan_or_inf(value):
|
||||
return is_nan(value) or np.isinf(value)
|
||||
|
||||
|
||||
class StartTraceback(Exception):
|
||||
"""These exceptions (and their tracebacks) can be skipped with `skip_exceptions`"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StartTracebackWithWorkerRank(StartTraceback):
|
||||
def __init__(self, worker_rank: int) -> None:
|
||||
super().__init__()
|
||||
self.worker_rank = worker_rank
|
||||
|
||||
def __reduce__(self):
|
||||
return (self.__class__, (self.worker_rank,))
|
||||
|
||||
|
||||
def skip_exceptions(exc: Optional[Exception]) -> Exception:
|
||||
"""Skip all contained `StartTracebacks` to reduce traceback output.
|
||||
|
||||
Returns a shallow copy of the exception with all `StartTracebacks` removed.
|
||||
|
||||
If the RAY_AIR_FULL_TRACEBACKS environment variable is set,
|
||||
the original exception (not a copy) is returned.
|
||||
"""
|
||||
should_not_shorten = bool(int(os.environ.get("RAY_AIR_FULL_TRACEBACKS", "0")))
|
||||
|
||||
if should_not_shorten:
|
||||
return exc
|
||||
|
||||
if isinstance(exc, StartTraceback):
|
||||
# If this is a StartTraceback, skip
|
||||
return skip_exceptions(exc.__cause__)
|
||||
|
||||
# Perform a shallow copy to prevent recursive __cause__/__context__.
|
||||
new_exc = copy.copy(exc).with_traceback(exc.__traceback__)
|
||||
|
||||
# Make sure nested exceptions are properly skipped.
|
||||
cause = getattr(exc, "__cause__", None)
|
||||
if cause:
|
||||
new_exc.__cause__ = skip_exceptions(cause)
|
||||
|
||||
return new_exc
|
||||
|
||||
|
||||
def exception_cause(exc: Optional[Exception]) -> Optional[Exception]:
|
||||
if not exc:
|
||||
return None
|
||||
|
||||
return getattr(exc, "__cause__", None)
|
||||
|
||||
|
||||
class RunnerThread(threading.Thread):
|
||||
"""Supervisor thread that runs your script."""
|
||||
|
||||
def __init__(self, *args, error_queue, **kwargs):
|
||||
threading.Thread.__init__(self, *args, **kwargs)
|
||||
self._error_queue = error_queue
|
||||
self._ret = None
|
||||
|
||||
def _propagate_exception(self, e: BaseException):
|
||||
try:
|
||||
# report the error but avoid indefinite blocking which would
|
||||
# prevent the exception from being propagated in the unlikely
|
||||
# case that something went terribly wrong
|
||||
self._error_queue.put(e, block=True, timeout=_ERROR_REPORT_TIMEOUT)
|
||||
except queue.Full:
|
||||
logger.critical(
|
||||
(
|
||||
"Runner Thread was unable to report error to main "
|
||||
"function runner thread. This means a previous error "
|
||||
"was not processed. This should never happen."
|
||||
)
|
||||
)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self._ret = self._target(*self._args, **self._kwargs)
|
||||
except StopIteration:
|
||||
logger.debug(
|
||||
(
|
||||
"Thread runner raised StopIteration. Interpreting it as a "
|
||||
"signal to terminate the thread without error."
|
||||
)
|
||||
)
|
||||
except SystemExit as e:
|
||||
# Do not propagate up for graceful termination.
|
||||
if e.code == 0:
|
||||
logger.debug(
|
||||
(
|
||||
"Thread runner raised SystemExit with error code 0. "
|
||||
"Interpreting it as a signal to terminate the thread "
|
||||
"without error."
|
||||
)
|
||||
)
|
||||
else:
|
||||
# If non-zero exit code, then raise exception to main thread.
|
||||
self._propagate_exception(e)
|
||||
except BaseException as e:
|
||||
# Propagate all other exceptions to the main thread.
|
||||
self._propagate_exception(e)
|
||||
|
||||
def join(self, timeout=None):
|
||||
super(RunnerThread, self).join(timeout)
|
||||
return self._ret
|
||||
@@ -0,0 +1,723 @@
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import _MISSING_TYPE, dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import pyarrow.fs
|
||||
|
||||
import ray
|
||||
from ray._common.utils import RESOURCE_CONSTRAINT_PREFIX
|
||||
from ray._private.thirdparty.tabulate.tabulate import tabulate
|
||||
from ray.util.annotations import PublicAPI, RayDeprecationWarning
|
||||
from ray.widgets import Template, make_table_html_repr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ray.tune.progress_reporter
|
||||
from ray.tune.callback import Callback
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
from ray.tune.experimental.output import AirVerbosity
|
||||
from ray.tune.search.sample import Domain
|
||||
from ray.tune.stopper import Stopper
|
||||
from ray.tune.utils.log import Verbosity
|
||||
|
||||
|
||||
# Dict[str, List] is to support `tune.grid_search`:
|
||||
# TODO(sumanthratna/matt): Upstream this to Tune.
|
||||
SampleRange = Union["Domain", Dict[str, List]]
|
||||
|
||||
|
||||
MAX = "max"
|
||||
MIN = "min"
|
||||
_DEPRECATED_VALUE = "DEPRECATED"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _repr_dataclass(
|
||||
obj: Any, *, default_values: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""A utility function to elegantly represent dataclasses.
|
||||
|
||||
In contrast to the default dataclass `__repr__`, which shows all parameters, this
|
||||
function only shows parameters with non-default values.
|
||||
|
||||
Args:
|
||||
obj: The dataclass to represent.
|
||||
default_values: An optional dictionary that maps field names to default values.
|
||||
Use this parameter to specify default values that are generated dynamically
|
||||
(e.g., in `__post_init__` or by a `default_factory`). If a default value
|
||||
isn't specified in `default_values`, then the default value is inferred from
|
||||
the `dataclass`.
|
||||
|
||||
Returns:
|
||||
A representation of the dataclass.
|
||||
"""
|
||||
if default_values is None:
|
||||
default_values = {}
|
||||
|
||||
non_default_values = {} # Maps field name to value.
|
||||
|
||||
def equals(value, default_value):
|
||||
# We need to special case None because of a bug in pyarrow:
|
||||
# https://github.com/apache/arrow/issues/38535
|
||||
if value is None and default_value is None:
|
||||
return True
|
||||
if value is None or default_value is None:
|
||||
return False
|
||||
return value == default_value
|
||||
|
||||
for field in fields(obj):
|
||||
value = getattr(obj, field.name)
|
||||
default_value = default_values.get(field.name, field.default)
|
||||
is_required = isinstance(field.default, _MISSING_TYPE)
|
||||
if is_required or not equals(value, default_value):
|
||||
non_default_values[field.name] = value
|
||||
|
||||
string = f"{obj.__class__.__name__}("
|
||||
string += ", ".join(
|
||||
f"{name}={value!r}" for name, value in non_default_values.items()
|
||||
)
|
||||
string += ")"
|
||||
|
||||
return string
|
||||
|
||||
|
||||
@dataclass
|
||||
@PublicAPI(stability="stable")
|
||||
class ScalingConfig:
|
||||
"""Configuration for scaling training.
|
||||
|
||||
For more details, see :ref:`train_scaling_config`.
|
||||
|
||||
Args:
|
||||
trainer_resources: Resources to allocate for the training coordinator.
|
||||
The training coordinator launches the worker group and executes
|
||||
the training function per worker, and this process does NOT require
|
||||
GPUs. The coordinator is always scheduled on the same node as the
|
||||
rank 0 worker, so one example use case is to set a minimum amount
|
||||
of resources (e.g. CPU memory) required by the rank 0 node.
|
||||
By default, this assigns 1 CPU to the training coordinator.
|
||||
|
||||
Accepts the same resource keys that Ray uses for scheduling tasks
|
||||
and actors (see :ref:`Resources <core-resources>`):
|
||||
|
||||
- ``"CPU"``: number of logical CPUs.
|
||||
- ``"GPU"``: number of logical GPUs.
|
||||
- ``"memory"``: heap memory reserved on the node, in bytes
|
||||
(for example, ``"memory": 1e9`` reserves 1 GB).
|
||||
- Any :ref:`custom resource <custom-resources>` name configured on
|
||||
your cluster (for example, ``"TPU": 1``, ``"special_hardware": 1``).
|
||||
|
||||
Keys are case-sensitive: use ``"CPU"`` and ``"GPU"`` (uppercase),
|
||||
and ``"memory"`` (lowercase).
|
||||
num_workers: The number of workers (Ray actors) to launch.
|
||||
Each worker will reserve 1 CPU by default. The number of CPUs
|
||||
reserved by each worker can be overridden with the
|
||||
``resources_per_worker`` argument.
|
||||
use_gpu: If True, training will be done on GPUs (1 per worker).
|
||||
Defaults to False. The number of GPUs reserved by each
|
||||
worker can be overridden with the ``resources_per_worker``
|
||||
argument.
|
||||
resources_per_worker: If specified, the resources
|
||||
defined in this Dict is reserved for each worker.
|
||||
Define the ``"CPU"`` key (case-sensitive) to
|
||||
override the number of CPUs used by each worker.
|
||||
|
||||
Accepts the same resource keys as ``trainer_resources``:
|
||||
|
||||
- ``"CPU"``: number of logical CPUs per worker.
|
||||
- ``"GPU"``: number of logical GPUs per worker. Prefer setting
|
||||
``use_gpu=True`` (which reserves 1 GPU per worker) and only
|
||||
override this key when you need a different per-worker count.
|
||||
- ``"memory"``: heap memory reserved per worker, in bytes
|
||||
(for example, ``"memory": 1e9`` reserves 1 GB per worker).
|
||||
- Any :ref:`custom resource <custom-resources>` name configured on
|
||||
your cluster (for example, ``"TPU": 1``, ``"special_hardware": 1``).
|
||||
|
||||
Keys are case-sensitive: use ``"CPU"`` and ``"GPU"`` (uppercase),
|
||||
and ``"memory"`` (lowercase).
|
||||
placement_strategy: The placement strategy to use for the
|
||||
placement group of the Ray actors. See :ref:`Placement Group
|
||||
Strategies <pgroup-strategy>` for the possible options.
|
||||
accelerator_type: [Experimental] If specified, Ray Train will launch the
|
||||
training coordinator and workers on the nodes with the specified type
|
||||
of accelerators.
|
||||
See :ref:`the available accelerator types <accelerator_types>`.
|
||||
Ensure that your cluster has instances with the specified accelerator type
|
||||
or is able to autoscale to fulfill the request.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.train import ScalingConfig
|
||||
scaling_config = ScalingConfig(
|
||||
# Number of distributed workers.
|
||||
num_workers=2,
|
||||
# Turn on/off GPU.
|
||||
use_gpu=True,
|
||||
# Assign extra CPU/GPU/custom resources per worker.
|
||||
resources_per_worker={"GPU": 1, "CPU": 1, "memory": 1e9, "custom": 1.0},
|
||||
# Try to schedule workers on different nodes.
|
||||
placement_strategy="SPREAD",
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
trainer_resources: Optional[Union[Dict, SampleRange]] = None
|
||||
num_workers: Union[int, SampleRange] = 1
|
||||
use_gpu: Union[bool, SampleRange] = False
|
||||
resources_per_worker: Optional[Union[Dict, SampleRange]] = None
|
||||
placement_strategy: Union[str, SampleRange] = "PACK"
|
||||
accelerator_type: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.resources_per_worker:
|
||||
if not self.use_gpu and self.num_gpus_per_worker > 0:
|
||||
raise ValueError(
|
||||
"`use_gpu` is False but `GPU` was found in "
|
||||
"`resources_per_worker`. Either set `use_gpu` to True or "
|
||||
"remove `GPU` from `resources_per_worker."
|
||||
)
|
||||
|
||||
if self.use_gpu and self.num_gpus_per_worker == 0:
|
||||
raise ValueError(
|
||||
"`use_gpu` is True but `GPU` is set to 0 in "
|
||||
"`resources_per_worker`. Either set `use_gpu` to False or "
|
||||
"request a positive number of `GPU` in "
|
||||
"`resources_per_worker."
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return _repr_dataclass(self)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
return make_table_html_repr(obj=self, title=type(self).__name__)
|
||||
|
||||
def __eq__(self, o: "ScalingConfig") -> bool:
|
||||
if not isinstance(o, type(self)):
|
||||
return False
|
||||
return self.as_placement_group_factory() == o.as_placement_group_factory()
|
||||
|
||||
@property
|
||||
def _resources_per_worker_not_none(self):
|
||||
if self.resources_per_worker is None:
|
||||
if self.use_gpu:
|
||||
# Note that we don't request any CPUs, which avoids possible
|
||||
# scheduling contention. Generally nodes have many more CPUs than
|
||||
# GPUs, so not requesting a CPU does not lead to oversubscription.
|
||||
resources_per_worker = {"GPU": 1}
|
||||
else:
|
||||
resources_per_worker = {"CPU": 1}
|
||||
else:
|
||||
resources_per_worker = {
|
||||
k: v for k, v in self.resources_per_worker.items() if v != 0
|
||||
}
|
||||
|
||||
if self.use_gpu:
|
||||
resources_per_worker.setdefault("GPU", 1)
|
||||
|
||||
if self.accelerator_type:
|
||||
accelerator = f"{RESOURCE_CONSTRAINT_PREFIX}{self.accelerator_type}"
|
||||
resources_per_worker.setdefault(accelerator, 0.001)
|
||||
return resources_per_worker
|
||||
|
||||
@property
|
||||
def _trainer_resources_not_none(self):
|
||||
if self.trainer_resources is None:
|
||||
if self.num_workers:
|
||||
# For Google Colab, don't allocate resources to the base Trainer.
|
||||
# Colab only has 2 CPUs, and because of this resource scarcity,
|
||||
# we have to be careful on where we allocate resources. Since Colab
|
||||
# is not distributed, the concern about many parallel Ray Tune trials
|
||||
# leading to all Trainers being scheduled on the head node if we set
|
||||
# `trainer_resources` to 0 is no longer applicable.
|
||||
try:
|
||||
import google.colab # noqa: F401
|
||||
|
||||
trainer_num_cpus = 0
|
||||
except ImportError:
|
||||
trainer_num_cpus = 1
|
||||
else:
|
||||
# If there are no additional workers, then always reserve 1 CPU for
|
||||
# the Trainer.
|
||||
trainer_num_cpus = 1
|
||||
|
||||
trainer_resources = {"CPU": trainer_num_cpus}
|
||||
else:
|
||||
trainer_resources = {
|
||||
k: v for k, v in self.trainer_resources.items() if v != 0
|
||||
}
|
||||
|
||||
return trainer_resources
|
||||
|
||||
@property
|
||||
def total_resources(self):
|
||||
"""Map of total resources required for the trainer."""
|
||||
total_resource_map = defaultdict(float, self._trainer_resources_not_none)
|
||||
for k, value in self._resources_per_worker_not_none.items():
|
||||
total_resource_map[k] += value * self.num_workers
|
||||
return dict(total_resource_map)
|
||||
|
||||
@property
|
||||
def num_cpus_per_worker(self):
|
||||
"""The number of CPUs to set per worker."""
|
||||
return self._resources_per_worker_not_none.get("CPU", 0)
|
||||
|
||||
@property
|
||||
def num_gpus_per_worker(self):
|
||||
"""The number of GPUs to set per worker."""
|
||||
return self._resources_per_worker_not_none.get("GPU", 0)
|
||||
|
||||
@property
|
||||
def additional_resources_per_worker(self):
|
||||
"""Resources per worker, not including CPU or GPU resources."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self._resources_per_worker_not_none.items()
|
||||
if k not in ["CPU", "GPU"]
|
||||
}
|
||||
|
||||
def as_placement_group_factory(self) -> "PlacementGroupFactory":
|
||||
"""Returns a PlacementGroupFactory to specify resources for Tune."""
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
|
||||
trainer_bundle = self._trainer_resources_not_none
|
||||
worker_bundle = self._resources_per_worker_not_none
|
||||
|
||||
# Colocate Trainer and rank0 worker by merging their bundles
|
||||
# Note: This empty bundle is required so that the Tune actor manager schedules
|
||||
# the Trainable onto the combined bundle while taking none of its resources,
|
||||
# rather than a non-empty head bundle.
|
||||
combined_bundle = dict(Counter(trainer_bundle) + Counter(worker_bundle))
|
||||
bundles = [{}, combined_bundle] + [worker_bundle] * (self.num_workers - 1)
|
||||
return PlacementGroupFactory(bundles, strategy=self.placement_strategy)
|
||||
|
||||
@classmethod
|
||||
def from_placement_group_factory(
|
||||
cls, pgf: "PlacementGroupFactory"
|
||||
) -> "ScalingConfig":
|
||||
"""Create a ScalingConfig from a Tune's PlacementGroupFactory
|
||||
|
||||
Note that this is only needed for ResourceChangingScheduler, which
|
||||
modifies a trial's PlacementGroupFactory but doesn't propagate
|
||||
the changes to ScalingConfig. TrainTrainable needs to reconstruct
|
||||
a ScalingConfig from on the trial's PlacementGroupFactory.
|
||||
"""
|
||||
|
||||
# pgf.bundles = [{trainer + worker}, {worker}, ..., {worker}]
|
||||
num_workers = len(pgf.bundles)
|
||||
combined_resources = pgf.bundles[0]
|
||||
resources_per_worker = pgf.bundles[-1]
|
||||
use_gpu = bool(resources_per_worker.get("GPU", False))
|
||||
placement_strategy = pgf.strategy
|
||||
|
||||
# In `as_placement_group_factory`, we merged the trainer resource into the
|
||||
# first worker resources bundle. We need to calculate the resources diff to
|
||||
# get the trainer resources.
|
||||
# Note: If there's only one worker, we won't be able to calculate the diff.
|
||||
# We'll have empty trainer bundle and assign all resources to the worker.
|
||||
trainer_resources = dict(
|
||||
Counter(combined_resources) - Counter(resources_per_worker)
|
||||
)
|
||||
|
||||
return ScalingConfig(
|
||||
trainer_resources=trainer_resources,
|
||||
num_workers=num_workers,
|
||||
use_gpu=use_gpu,
|
||||
resources_per_worker=resources_per_worker,
|
||||
placement_strategy=placement_strategy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@PublicAPI(stability="stable")
|
||||
class FailureConfig:
|
||||
"""Configuration related to failure handling of each training/tuning run.
|
||||
|
||||
Args:
|
||||
max_failures: Tries to recover a run at least this many times.
|
||||
Will recover from the latest checkpoint if present.
|
||||
Setting to -1 will lead to infinite recovery retries.
|
||||
Setting to 0 will disable retries. Defaults to 0.
|
||||
fail_fast: Whether to fail upon the first error.
|
||||
If fail_fast='raise' provided, the original error during training will be
|
||||
immediately raised. fail_fast='raise' can easily leak resources and
|
||||
should be used with caution.
|
||||
"""
|
||||
|
||||
max_failures: int = 0
|
||||
fail_fast: Union[bool, str] = False
|
||||
|
||||
def __post_init__(self):
|
||||
# Same check as in TuneController
|
||||
if not (isinstance(self.fail_fast, bool) or self.fail_fast.upper() == "RAISE"):
|
||||
raise ValueError(
|
||||
"fail_fast must be one of {bool, 'raise'}. " f"Got {self.fail_fast}."
|
||||
)
|
||||
|
||||
# Same check as in tune.run
|
||||
if self.fail_fast and self.max_failures != 0:
|
||||
raise ValueError(
|
||||
f"max_failures must be 0 if fail_fast={repr(self.fail_fast)}."
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return _repr_dataclass(self)
|
||||
|
||||
def _repr_html_(self):
|
||||
return Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
{
|
||||
"Setting": ["Max failures", "Fail fast"],
|
||||
"Value": [self.max_failures, self.fail_fast],
|
||||
},
|
||||
tablefmt="html",
|
||||
showindex=False,
|
||||
headers="keys",
|
||||
),
|
||||
max_height="none",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@PublicAPI(stability="stable")
|
||||
class CheckpointConfig:
|
||||
"""Configurable parameters for defining the checkpointing strategy.
|
||||
|
||||
Default behavior is to persist all checkpoints to disk. If
|
||||
``num_to_keep`` is set, the default retention policy is to keep the
|
||||
checkpoints with maximum timestamp, i.e. the most recent checkpoints.
|
||||
|
||||
Args:
|
||||
num_to_keep: The number of checkpoints to keep
|
||||
on disk for this run. If a checkpoint is persisted to disk after
|
||||
there are already this many checkpoints, then an existing
|
||||
checkpoint will be deleted. If this is ``None`` then checkpoints
|
||||
will not be deleted. Must be >= 1.
|
||||
checkpoint_score_attribute: The attribute that will be used to
|
||||
score checkpoints to determine which checkpoints should be kept
|
||||
on disk when there are greater than ``num_to_keep`` checkpoints.
|
||||
This attribute must be a key from the checkpoint
|
||||
dictionary which has a numerical value. Per default, the last
|
||||
checkpoints will be kept.
|
||||
checkpoint_score_order: Either "max" or "min".
|
||||
If "max", then checkpoints with highest values of
|
||||
``checkpoint_score_attribute`` will be kept.
|
||||
If "min", then checkpoints with lowest values of
|
||||
``checkpoint_score_attribute`` will be kept.
|
||||
checkpoint_frequency: Number of iterations between checkpoints. If 0
|
||||
this will disable checkpointing.
|
||||
Please note that most trainers will still save one checkpoint at
|
||||
the end of training.
|
||||
This attribute is only supported
|
||||
by trainers that don't take in custom training loops.
|
||||
checkpoint_at_end: If True, will save a checkpoint at the end of training.
|
||||
This attribute is only supported by trainers that don't take in
|
||||
custom training loops. Defaults to True for trainers that support it
|
||||
and False for generic function trainables.
|
||||
_checkpoint_keep_all_ranks: This experimental config is deprecated.
|
||||
This behavior is now controlled by reporting `checkpoint=None`
|
||||
in the workers that shouldn't persist a checkpoint.
|
||||
For example, if you only want the rank 0 worker to persist a checkpoint
|
||||
(e.g., in standard data parallel training), then you should save and
|
||||
report a checkpoint if `ray.train.get_context().get_world_rank() == 0`
|
||||
and `None` otherwise.
|
||||
_checkpoint_upload_from_workers: This experimental config is deprecated.
|
||||
Uploading checkpoint directly from the worker is now the default behavior.
|
||||
"""
|
||||
|
||||
num_to_keep: Optional[int] = None
|
||||
checkpoint_score_attribute: Optional[str] = None
|
||||
checkpoint_score_order: Optional[str] = MAX
|
||||
checkpoint_frequency: Optional[int] = 0
|
||||
checkpoint_at_end: Optional[bool] = None
|
||||
_checkpoint_keep_all_ranks: Optional[bool] = _DEPRECATED_VALUE
|
||||
_checkpoint_upload_from_workers: Optional[bool] = _DEPRECATED_VALUE
|
||||
|
||||
def __post_init__(self):
|
||||
if self._checkpoint_keep_all_ranks != _DEPRECATED_VALUE:
|
||||
raise DeprecationWarning(
|
||||
"The experimental `_checkpoint_keep_all_ranks` config is deprecated. "
|
||||
"This behavior is now controlled by reporting `checkpoint=None` "
|
||||
"in the workers that shouldn't persist a checkpoint. "
|
||||
"For example, if you only want the rank 0 worker to persist a "
|
||||
"checkpoint (e.g., in standard data parallel training), "
|
||||
"then you should save and report a checkpoint if "
|
||||
"`ray.train.get_context().get_world_rank() == 0` "
|
||||
"and `None` otherwise."
|
||||
)
|
||||
|
||||
if self._checkpoint_upload_from_workers != _DEPRECATED_VALUE:
|
||||
raise DeprecationWarning(
|
||||
"The experimental `_checkpoint_upload_from_workers` config is "
|
||||
"deprecated. Uploading checkpoint directly from the worker is "
|
||||
"now the default behavior."
|
||||
)
|
||||
|
||||
if self.num_to_keep is not None and self.num_to_keep <= 0:
|
||||
raise ValueError(
|
||||
f"Received invalid num_to_keep: "
|
||||
f"{self.num_to_keep}. "
|
||||
f"Must be None or an integer >= 1."
|
||||
)
|
||||
if self.checkpoint_score_order not in (MAX, MIN):
|
||||
raise ValueError(
|
||||
f"checkpoint_score_order must be either " f'"{MAX}" or "{MIN}".'
|
||||
)
|
||||
|
||||
if self.checkpoint_frequency < 0:
|
||||
raise ValueError(
|
||||
f"checkpoint_frequency must be >=0, got {self.checkpoint_frequency}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return _repr_dataclass(self)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
if self.num_to_keep is None:
|
||||
num_to_keep_repr = "All"
|
||||
else:
|
||||
num_to_keep_repr = self.num_to_keep
|
||||
|
||||
if self.checkpoint_score_attribute is None:
|
||||
checkpoint_score_attribute_repr = "Most recent"
|
||||
else:
|
||||
checkpoint_score_attribute_repr = self.checkpoint_score_attribute
|
||||
|
||||
if self.checkpoint_at_end is None:
|
||||
checkpoint_at_end_repr = ""
|
||||
else:
|
||||
checkpoint_at_end_repr = self.checkpoint_at_end
|
||||
|
||||
return Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
{
|
||||
"Setting": [
|
||||
"Number of checkpoints to keep",
|
||||
"Checkpoint score attribute",
|
||||
"Checkpoint score order",
|
||||
"Checkpoint frequency",
|
||||
"Checkpoint at end",
|
||||
],
|
||||
"Value": [
|
||||
num_to_keep_repr,
|
||||
checkpoint_score_attribute_repr,
|
||||
self.checkpoint_score_order,
|
||||
self.checkpoint_frequency,
|
||||
checkpoint_at_end_repr,
|
||||
],
|
||||
},
|
||||
tablefmt="html",
|
||||
showindex=False,
|
||||
headers="keys",
|
||||
),
|
||||
max_height="none",
|
||||
)
|
||||
|
||||
@property
|
||||
def _tune_legacy_checkpoint_score_attr(self) -> Optional[str]:
|
||||
"""Same as ``checkpoint_score_attr`` in ``tune.run``.
|
||||
|
||||
Only used for Legacy API compatibility.
|
||||
"""
|
||||
if self.checkpoint_score_attribute is None:
|
||||
return self.checkpoint_score_attribute
|
||||
prefix = ""
|
||||
if self.checkpoint_score_order == MIN:
|
||||
prefix = "min-"
|
||||
return f"{prefix}{self.checkpoint_score_attribute}"
|
||||
|
||||
|
||||
@dataclass
|
||||
@PublicAPI(stability="stable")
|
||||
class RunConfig:
|
||||
"""Runtime configuration for training and tuning runs.
|
||||
|
||||
Upon resuming from a training or tuning run checkpoint,
|
||||
Ray Train/Tune will automatically apply the RunConfig from
|
||||
the previously checkpointed run.
|
||||
|
||||
Args:
|
||||
name: Name of the trial or experiment. If not provided, will be deduced
|
||||
from the Trainable.
|
||||
storage_path: [Beta] Path where all results and checkpoints are persisted.
|
||||
Can be a local directory or a destination on cloud storage.
|
||||
For multi-node training/tuning runs, this must be set to a
|
||||
shared storage location (e.g., S3, NFS).
|
||||
This defaults to the local ``~/ray_results`` directory.
|
||||
storage_filesystem: [Beta] A custom filesystem to use for storage.
|
||||
If this is provided, `storage_path` should be a path with its
|
||||
prefix stripped (e.g., `s3://bucket/path` -> `bucket/path`).
|
||||
failure_config: Failure mode configuration.
|
||||
checkpoint_config: Checkpointing configuration.
|
||||
sync_config: Configuration object for syncing. See train.SyncConfig.
|
||||
verbose: 0, 1, or 2. Verbosity mode.
|
||||
0 = silent, 1 = default, 2 = verbose. Defaults to 1.
|
||||
If the ``RAY_AIR_NEW_OUTPUT=1`` environment variable is set,
|
||||
uses the old verbosity settings:
|
||||
0 = silent, 1 = only status updates, 2 = status and brief
|
||||
results, 3 = status and detailed results.
|
||||
stop: Stop conditions to consider. Refer to ray.tune.stopper.Stopper
|
||||
for more info. Stoppers should be serializable.
|
||||
callbacks: [DeveloperAPI] Callbacks to invoke.
|
||||
Refer to ray.tune.callback.Callback for more info.
|
||||
Callbacks should be serializable.
|
||||
Currently only stateless callbacks are supported for resumed runs.
|
||||
(any state of the callback will not be checkpointed by Tune
|
||||
and thus will not take effect in resumed runs).
|
||||
progress_reporter: [DeveloperAPI] Progress reporter for reporting
|
||||
intermediate experiment progress. Defaults to CLIReporter if
|
||||
running in command-line, or JupyterNotebookReporter if running in
|
||||
a Jupyter notebook.
|
||||
log_to_file: [DeveloperAPI] Log stdout and stderr to files in
|
||||
trial directories. If this is `False` (default), no files
|
||||
are written. If `true`, outputs are written to `trialdir/stdout`
|
||||
and `trialdir/stderr`, respectively. If this is a single string,
|
||||
this is interpreted as a file relative to the trialdir, to which
|
||||
both streams are written. If this is a Sequence (e.g. a Tuple),
|
||||
it has to have length 2 and the elements indicate the files to
|
||||
which stdout and stderr are written, respectively.
|
||||
|
||||
"""
|
||||
|
||||
name: Optional[str] = None
|
||||
storage_path: Optional[str] = None
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None
|
||||
failure_config: Optional[FailureConfig] = None
|
||||
checkpoint_config: Optional[CheckpointConfig] = None
|
||||
sync_config: Optional["ray.train.SyncConfig"] = None
|
||||
verbose: Optional[Union[int, "AirVerbosity", "Verbosity"]] = None
|
||||
stop: Optional[Union[Mapping, "Stopper", Callable[[str, Mapping], bool]]] = None
|
||||
callbacks: Optional[List["Callback"]] = None
|
||||
progress_reporter: Optional["ray.tune.progress_reporter.ProgressReporter"] = None
|
||||
log_to_file: Union[bool, str, Tuple[str, str]] = False
|
||||
|
||||
# Deprecated
|
||||
local_dir: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
from ray.train import SyncConfig
|
||||
from ray.train.constants import DEFAULT_STORAGE_PATH
|
||||
from ray.tune.experimental.output import AirVerbosity, get_air_verbosity
|
||||
|
||||
if self.local_dir is not None:
|
||||
raise DeprecationWarning(
|
||||
"The `RunConfig(local_dir)` argument is deprecated. "
|
||||
"You should set the `RunConfig(storage_path)` instead."
|
||||
"See the docs: https://docs.ray.io/en/latest/train/user-guides/"
|
||||
"persistent-storage.html#setting-the-local-staging-directory"
|
||||
)
|
||||
|
||||
if self.storage_path is None:
|
||||
self.storage_path = DEFAULT_STORAGE_PATH
|
||||
|
||||
# TODO(justinvyu): [Deprecated]
|
||||
ray_storage_uri: Optional[str] = os.environ.get("RAY_STORAGE")
|
||||
if ray_storage_uri is not None:
|
||||
logger.info(
|
||||
"Using configured Ray Storage URI as the `storage_path`: "
|
||||
f"{ray_storage_uri}"
|
||||
)
|
||||
warnings.warn(
|
||||
"The `RAY_STORAGE` environment variable is deprecated. "
|
||||
"Please use `RunConfig(storage_path)` instead.",
|
||||
RayDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.storage_path = ray_storage_uri
|
||||
|
||||
if not self.failure_config:
|
||||
self.failure_config = FailureConfig()
|
||||
|
||||
if not self.sync_config:
|
||||
self.sync_config = SyncConfig()
|
||||
|
||||
if not self.checkpoint_config:
|
||||
self.checkpoint_config = CheckpointConfig()
|
||||
|
||||
# Save the original verbose value to check for deprecations
|
||||
self._verbose = self.verbose
|
||||
if self.verbose is None:
|
||||
# Default `verbose` value. For new output engine,
|
||||
# this is AirVerbosity.DEFAULT.
|
||||
# For old output engine, this is Verbosity.V3_TRIAL_DETAILS
|
||||
# Todo (krfricke): Currently uses number to pass test_configs::test_repr
|
||||
self.verbose = get_air_verbosity(AirVerbosity.DEFAULT) or 3
|
||||
|
||||
if isinstance(self.storage_path, Path):
|
||||
self.storage_path = self.storage_path.as_posix()
|
||||
|
||||
def __repr__(self):
|
||||
from ray.train import SyncConfig
|
||||
|
||||
return _repr_dataclass(
|
||||
self,
|
||||
default_values={
|
||||
"failure_config": FailureConfig(),
|
||||
"sync_config": SyncConfig(),
|
||||
"checkpoint_config": CheckpointConfig(),
|
||||
},
|
||||
)
|
||||
|
||||
def _repr_html_(self) -> str:
|
||||
reprs = []
|
||||
if self.failure_config is not None:
|
||||
reprs.append(
|
||||
Template("title_data_mini.html.j2").render(
|
||||
title="Failure Config", data=self.failure_config._repr_html_()
|
||||
)
|
||||
)
|
||||
if self.sync_config is not None:
|
||||
reprs.append(
|
||||
Template("title_data_mini.html.j2").render(
|
||||
title="Sync Config", data=self.sync_config._repr_html_()
|
||||
)
|
||||
)
|
||||
if self.checkpoint_config is not None:
|
||||
reprs.append(
|
||||
Template("title_data_mini.html.j2").render(
|
||||
title="Checkpoint Config", data=self.checkpoint_config._repr_html_()
|
||||
)
|
||||
)
|
||||
|
||||
# Create a divider between each displayed repr
|
||||
subconfigs = [Template("divider.html.j2").render()] * (2 * len(reprs) - 1)
|
||||
subconfigs[::2] = reprs
|
||||
|
||||
settings = Template("scrollableTable.html.j2").render(
|
||||
table=tabulate(
|
||||
{
|
||||
"Name": self.name,
|
||||
"Local results directory": self.local_dir,
|
||||
"Verbosity": self.verbose,
|
||||
"Log to file": self.log_to_file,
|
||||
}.items(),
|
||||
tablefmt="html",
|
||||
headers=["Setting", "Value"],
|
||||
showindex=False,
|
||||
),
|
||||
max_height="300px",
|
||||
)
|
||||
|
||||
return Template("title_data.html.j2").render(
|
||||
title="RunConfig",
|
||||
data=Template("run_config.html.j2").render(
|
||||
subconfigs=subconfigs,
|
||||
settings=settings,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
# Key to denote the preprocessor in the checkpoint dict.
|
||||
PREPROCESSOR_KEY = "_preprocessor"
|
||||
|
||||
# Key to denote the model in the checkpoint dict.
|
||||
MODEL_KEY = "model"
|
||||
|
||||
# Key to denote which dataset is the evaluation dataset.
|
||||
# Only used in trainers which do not support multiple
|
||||
# evaluation datasets.
|
||||
EVALUATION_DATASET_KEY = "evaluation"
|
||||
|
||||
# Key to denote which dataset is the training dataset.
|
||||
# This is the dataset that the preprocessor is fit on.
|
||||
TRAIN_DATASET_KEY = "train"
|
||||
|
||||
# Name to use for the column when representing tensors in table format.
|
||||
TENSOR_COLUMN_NAME = "__value__"
|
||||
|
||||
# The maximum length of strings returned by `__repr__` for AIR objects constructed with
|
||||
# default values.
|
||||
MAX_REPR_LENGTH = int(80 * 1.5)
|
||||
|
||||
# Timeout used when putting exceptions raised by runner thread into the queue.
|
||||
_ERROR_REPORT_TIMEOUT = 10
|
||||
|
||||
# Timeout when fetching new results after signaling the training function to continue.
|
||||
_RESULT_FETCH_TIMEOUT = 0.2
|
||||
|
||||
# Timeout for fetching exceptions raised by the training function.
|
||||
_ERROR_FETCH_TIMEOUT = 1
|
||||
|
||||
# The key used to identify whether we have already warned about ray.air.session
|
||||
# functions being used outside of the session
|
||||
SESSION_MISUSE_LOG_ONCE_KEY = "air_warn_session_misuse"
|
||||
|
||||
# Name of attribute in Checkpoint storing current Tune ID for restoring
|
||||
# training with Ray Train
|
||||
CHECKPOINT_ID_ATTR = "_current_checkpoint_id"
|
||||
|
||||
# Name of the marker dropped by the Trainable. If a worker detects
|
||||
# the presence of the marker in the trial dir, it will use lazy
|
||||
# checkpointing.
|
||||
LAZY_CHECKPOINT_MARKER_FILE = ".lazy_checkpoint_marker"
|
||||
|
||||
|
||||
# The timestamp of when the result is generated.
|
||||
# Default to when the result is processed by tune.
|
||||
TIMESTAMP = "timestamp"
|
||||
|
||||
# (Auto-filled) Time in seconds this iteration took to run.
|
||||
# This may be overridden to override the system-computed time difference.
|
||||
TIME_THIS_ITER_S = "time_this_iter_s"
|
||||
|
||||
# (Auto-filled) The index of this training iteration.
|
||||
TRAINING_ITERATION = "training_iteration"
|
||||
|
||||
# File that stores parameters of the trial.
|
||||
EXPR_PARAM_FILE = "params.json"
|
||||
|
||||
# Pickle File that stores parameters of the trial.
|
||||
EXPR_PARAM_PICKLE_FILE = "params.pkl"
|
||||
|
||||
# File that stores the progress of the trial.
|
||||
EXPR_PROGRESS_FILE = "progress.csv"
|
||||
|
||||
# File that stores results of the trial.
|
||||
EXPR_RESULT_FILE = "result.json"
|
||||
|
||||
# File that stores the pickled error file
|
||||
EXPR_ERROR_PICKLE_FILE = "error.pkl"
|
||||
|
||||
# File that stores the error file
|
||||
EXPR_ERROR_FILE = "error.txt"
|
||||
|
||||
# File that stores the checkpoint metadata
|
||||
CHECKPOINT_TUNE_METADATA_FILE = ".tune_metadata"
|
||||
|
||||
# ==================================================
|
||||
# Environment Variables
|
||||
# ==================================================
|
||||
|
||||
# Integer value which if set will copy files in reported AIR directory
|
||||
# checkpoints instead of moving them (if worker is on the same node as Trainable)
|
||||
COPY_DIRECTORY_CHECKPOINTS_INSTEAD_OF_MOVING_ENV = (
|
||||
"TRAIN_COPY_DIRECTORY_CHECKPOINTS_INSTEAD_OF_MOVING"
|
||||
)
|
||||
|
||||
# NOTE: When adding a new environment variable, please track it in this list.
|
||||
# TODO(ml-team): Most env var constants should get moved here.
|
||||
AIR_ENV_VARS = {
|
||||
COPY_DIRECTORY_CHECKPOINTS_INSTEAD_OF_MOVING_ENV,
|
||||
"RAY_AIR_FULL_TRACEBACKS",
|
||||
"RAY_AIR_NEW_OUTPUT",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import TYPE_CHECKING, Dict, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy
|
||||
import pandas # noqa: F401
|
||||
import pyarrow
|
||||
|
||||
# TODO de-dup with ray.data.block.DataBatch
|
||||
DataBatchType = Union[
|
||||
"numpy.ndarray", "pyarrow.Table", "pandas.DataFrame", Dict[str, "numpy.ndarray"]
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from ray.air.execution.resources.fixed import FixedResourceManager
|
||||
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
|
||||
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
|
||||
from ray.air.execution.resources.resource_manager import ResourceManager
|
||||
|
||||
__all__ = [
|
||||
"ResourceRequest",
|
||||
"AcquiredResources",
|
||||
"ResourceManager",
|
||||
"FixedResourceManager",
|
||||
"PlacementGroupResourceManager",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from ray.air.execution._internal.actor_manager import RayActorManager
|
||||
from ray.air.execution._internal.barrier import Barrier
|
||||
from ray.air.execution._internal.tracked_actor import TrackedActor
|
||||
|
||||
__all__ = ["Barrier", "RayActorManager", "TrackedActor"]
|
||||
@@ -0,0 +1,906 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
|
||||
|
||||
import ray
|
||||
from ray.air.execution._internal.event_manager import RayEventManager
|
||||
from ray.air.execution._internal.tracked_actor import TrackedActor
|
||||
from ray.air.execution._internal.tracked_actor_task import TrackedActorTask
|
||||
from ray.air.execution.resources import (
|
||||
AcquiredResources,
|
||||
ResourceManager,
|
||||
ResourceRequest,
|
||||
)
|
||||
from ray.exceptions import RayActorError, RayTaskError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RayActorManager:
|
||||
"""Management class for Ray actors and actor tasks.
|
||||
|
||||
This class provides an event-based management interface for actors, and
|
||||
actor tasks.
|
||||
|
||||
The manager can be used to start actors, stop actors, and schedule and
|
||||
track task futures on these actors.
|
||||
The manager will then invoke callbacks related to the tracked entities.
|
||||
|
||||
For instance, when an actor is added with
|
||||
:meth:`add_actor() <RayActorManager.add_actor>`,
|
||||
a :ref:`TrackedActor <ray.air.execution._internal.tracked_actor.TrackedActor`
|
||||
object is returned. An ``on_start`` callback can be specified that is invoked
|
||||
once the actor successfully started. Similarly, ``on_stop`` and ``on_error``
|
||||
can be used to specify callbacks relating to the graceful or ungraceful
|
||||
end of an actor's lifetime.
|
||||
|
||||
When scheduling an actor task using
|
||||
:meth:`schedule_actor_task()
|
||||
<ray.air.execution._internal.actor_manager.RayActorManager.schedule_actor_task>`,
|
||||
an ``on_result`` callback can be specified that is invoked when the task
|
||||
successfully resolves, and an ``on_error`` callback will resolve when the
|
||||
task fails.
|
||||
|
||||
The RayActorManager does not implement any true asynchronous processing. Control
|
||||
has to be explicitly yielded to the event manager via :meth:`RayActorManager.next`.
|
||||
Callbacks will only be invoked when control is with the RayActorManager, and
|
||||
callbacks will always be executed sequentially in order of arriving events.
|
||||
|
||||
Args:
|
||||
resource_manager: Resource manager used to request resources for the actors.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.execution import ResourceRequest
|
||||
from ray.air.execution._internal import RayActorManager
|
||||
|
||||
actor_manager = RayActorManager()
|
||||
|
||||
# Request an actor
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
ActorClass,
|
||||
kwargs={},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=actor_start_callback,
|
||||
on_stop=actor_stop_callback,
|
||||
on_error=actor_error_callback
|
||||
)
|
||||
|
||||
# Yield control to event manager to start actor
|
||||
actor_manager.next()
|
||||
|
||||
# Start task on the actor (ActorClass.foo.remote())
|
||||
tracked_actor_task = actor_manager.schedule_actor_task(
|
||||
tracked_actor,
|
||||
method_name="foo",
|
||||
on_result=task_result_callback,
|
||||
on_error=task_error_callback
|
||||
)
|
||||
|
||||
# Again yield control to event manager to process task futures
|
||||
actor_manager.wait()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, resource_manager: ResourceManager):
|
||||
self._resource_manager: ResourceManager = resource_manager
|
||||
|
||||
self._actor_state_events = RayEventManager()
|
||||
self._actor_task_events = RayEventManager()
|
||||
|
||||
# ---
|
||||
# Tracked actor futures.
|
||||
|
||||
# This maps TrackedActor objects to their futures. We use this to see if an
|
||||
# actor has any futures scheduled and to remove them when we terminate an actor.
|
||||
|
||||
# Actors to actor task futures
|
||||
self._tracked_actors_to_task_futures: Dict[
|
||||
TrackedActor, Set[ray.ObjectRef]
|
||||
] = defaultdict(set)
|
||||
|
||||
# Actors to actor state futures (start/terminate)
|
||||
self._tracked_actors_to_state_futures: Dict[
|
||||
TrackedActor, Set[ray.ObjectRef]
|
||||
] = defaultdict(set)
|
||||
|
||||
# ---
|
||||
# Pending actors.
|
||||
# We use three dicts for actors that are requested but not yet started.
|
||||
|
||||
# This dict keeps a list of actors associated with each resource request.
|
||||
# We use this to start actors in the correct order when their resources
|
||||
# become available.
|
||||
self._resource_request_to_pending_actors: Dict[
|
||||
ResourceRequest, List[TrackedActor]
|
||||
] = defaultdict(list)
|
||||
|
||||
# This dict stores the actor class, kwargs, and resource request of
|
||||
# pending actors. Once the resources are available, we start the remote
|
||||
# actor class with its args. We need the resource request to cancel it
|
||||
# if needed.
|
||||
self._pending_actors_to_attrs: Dict[
|
||||
TrackedActor, Tuple[Type, Dict[str, Any], ResourceRequest]
|
||||
] = {}
|
||||
|
||||
# This dict keeps track of cached actor tasks. We can't schedule actor
|
||||
# tasks before the actor is actually scheduled/live. So when the caller
|
||||
# tries to schedule a task, we cache it here, and schedule it once the
|
||||
# actor is started.
|
||||
self._pending_actors_to_enqueued_actor_tasks: Dict[
|
||||
TrackedActor, List[Tuple[TrackedActorTask, str, Tuple[Any], Dict[str, Any]]]
|
||||
] = defaultdict(list)
|
||||
|
||||
# ---
|
||||
# Live actors.
|
||||
# We keep one dict for actors that are currently running and a set of
|
||||
# actors that we should forcefully kill.
|
||||
|
||||
# This dict associates the TrackedActor object with the Ray actor handle
|
||||
# and the resources associated to the actor. We use it to schedule the
|
||||
# actual ray tasks, and to return the resources when the actor stopped.
|
||||
self._live_actors_to_ray_actors_resources: Dict[
|
||||
TrackedActor, Tuple[ray.actor.ActorHandle, AcquiredResources]
|
||||
] = {}
|
||||
self._live_resource_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
# This dict contains all actors that should be killed (after calling
|
||||
# `remove_actor()`). Kill requests will be handled in wait().
|
||||
self._live_actors_to_kill: Set[TrackedActor] = set()
|
||||
|
||||
# Track failed actors
|
||||
self._failed_actor_ids: Set[int] = set()
|
||||
|
||||
def next(self, timeout: Optional[Union[int, float]] = None) -> bool:
|
||||
"""Yield control to event manager to await the next event and invoke callbacks.
|
||||
|
||||
Calling this method will wait for up to ``timeout`` seconds for the next
|
||||
event to arrive.
|
||||
|
||||
When events arrive, callbacks relating to the events will be
|
||||
invoked. A timeout of ``None`` will block until the next event arrives.
|
||||
|
||||
Note:
|
||||
If an actor task fails with a ``RayActorError``, this is one event,
|
||||
but it may trigger _two_ `on_error` callbacks: One for the actor,
|
||||
and one for the task.
|
||||
|
||||
Note:
|
||||
The ``timeout`` argument is used for pure waiting time for events. It does
|
||||
not include time spent on processing callbacks. Depending on the processing
|
||||
time of the callbacks, it can take much longer for this function to
|
||||
return than the specified timeout.
|
||||
|
||||
Args:
|
||||
timeout: Timeout in seconds to wait for next event.
|
||||
|
||||
Returns:
|
||||
True if at least one event was processed.
|
||||
|
||||
"""
|
||||
# First issue any pending forceful actor kills
|
||||
actor_killed = self._try_kill_actor()
|
||||
|
||||
# We always try to start actors as this won't trigger an event callback
|
||||
self._try_start_actors()
|
||||
|
||||
# If an actor was killed, this was our event, and we return.
|
||||
if actor_killed:
|
||||
return True
|
||||
|
||||
# Otherwise, collect all futures and await the next.
|
||||
resource_futures = self._resource_manager.get_resource_futures()
|
||||
actor_state_futures = self._actor_state_events.get_futures()
|
||||
actor_task_futures = self._actor_task_events.get_futures()
|
||||
|
||||
# Shuffle state futures
|
||||
shuffled_state_futures = list(actor_state_futures)
|
||||
random.shuffle(shuffled_state_futures)
|
||||
|
||||
# Shuffle task futures
|
||||
shuffled_task_futures = list(actor_task_futures)
|
||||
random.shuffle(shuffled_task_futures)
|
||||
|
||||
# Prioritize resource futures over actor state over task futures
|
||||
all_futures = resource_futures + shuffled_state_futures + shuffled_task_futures
|
||||
|
||||
start_wait = time.monotonic()
|
||||
ready, _ = ray.wait(all_futures, num_returns=1, timeout=timeout)
|
||||
|
||||
if not ready:
|
||||
return False
|
||||
|
||||
[future] = ready
|
||||
|
||||
if future in actor_state_futures:
|
||||
self._actor_state_events.resolve_future(future)
|
||||
elif future in actor_task_futures:
|
||||
self._actor_task_events.resolve_future(future)
|
||||
else:
|
||||
self._handle_ready_resource_future()
|
||||
# Ready resource futures don't count as one event as they don't trigger
|
||||
# any callbacks. So we repeat until we hit anything that is not a resource
|
||||
# future.
|
||||
time_taken = time.monotonic() - start_wait
|
||||
return self.next(
|
||||
timeout=max(1e-9, timeout - time_taken) if timeout is not None else None
|
||||
)
|
||||
|
||||
self._try_start_actors()
|
||||
return True
|
||||
|
||||
def _actor_start_resolved(self, tracked_actor: TrackedActor, future: ray.ObjectRef):
|
||||
"""Callback to be invoked when actor started"""
|
||||
self._tracked_actors_to_state_futures[tracked_actor].remove(future)
|
||||
|
||||
if tracked_actor._on_start:
|
||||
tracked_actor._on_start(tracked_actor)
|
||||
|
||||
def _actor_stop_resolved(self, tracked_actor: TrackedActor):
|
||||
"""Callback to be invoked when actor stopped"""
|
||||
self._cleanup_actor(tracked_actor=tracked_actor)
|
||||
|
||||
if tracked_actor._on_stop:
|
||||
tracked_actor._on_stop(tracked_actor)
|
||||
|
||||
def _actor_start_failed(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
"""Callback to be invoked when actor start/stop failed"""
|
||||
self._failed_actor_ids.add(tracked_actor.actor_id)
|
||||
|
||||
self._cleanup_actor(tracked_actor=tracked_actor)
|
||||
|
||||
if tracked_actor._on_error:
|
||||
tracked_actor._on_error(tracked_actor, exception)
|
||||
|
||||
def _actor_task_failed(
|
||||
self, tracked_actor_task: TrackedActorTask, exception: Exception
|
||||
):
|
||||
"""Handle an actor task future that became ready.
|
||||
|
||||
- On actor error, trigger actor error callback AND error task error callback
|
||||
- On task error, trigger actor task error callback
|
||||
- On success, trigger actor task result callback
|
||||
"""
|
||||
tracked_actor = tracked_actor_task._tracked_actor
|
||||
|
||||
if isinstance(exception, RayActorError):
|
||||
self._failed_actor_ids.add(tracked_actor.actor_id)
|
||||
|
||||
# Clean up any references to the actor and its futures
|
||||
self._cleanup_actor(tracked_actor=tracked_actor)
|
||||
|
||||
# Handle actor state callbacks
|
||||
if tracked_actor._on_error:
|
||||
tracked_actor._on_error(tracked_actor, exception)
|
||||
|
||||
# Then trigger actor task error callback
|
||||
if tracked_actor_task._on_error:
|
||||
tracked_actor_task._on_error(tracked_actor, exception)
|
||||
|
||||
elif isinstance(exception, RayTaskError):
|
||||
# Otherwise only the task failed. Invoke callback
|
||||
if tracked_actor_task._on_error:
|
||||
tracked_actor_task._on_error(tracked_actor, exception)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Caught unexpected exception: {exception}"
|
||||
) from exception
|
||||
|
||||
def _actor_task_resolved(self, tracked_actor_task: TrackedActorTask, result: Any):
|
||||
tracked_actor = tracked_actor_task._tracked_actor
|
||||
|
||||
# Trigger actor task result callback
|
||||
if tracked_actor_task._on_result:
|
||||
tracked_actor_task._on_result(tracked_actor, result)
|
||||
|
||||
def _handle_ready_resource_future(self):
|
||||
"""Handle a resource future that became ready.
|
||||
|
||||
- Update state of the resource manager
|
||||
- Try to start one actor
|
||||
"""
|
||||
# Force resource manager to update internal state
|
||||
self._resource_manager.update_state()
|
||||
# We handle resource futures one by one, so only try to start 1 actor at a time
|
||||
self._try_start_actors(max_actors=1)
|
||||
|
||||
def _try_start_actors(self, max_actors: Optional[int] = None) -> int:
|
||||
"""Try to start up to ``max_actors`` actors.
|
||||
|
||||
This function will iterate through all resource requests we collected for
|
||||
pending actors. As long as a resource request can be fulfilled (resources
|
||||
are available), we try to start as many actors as possible.
|
||||
|
||||
This will schedule a `Actor.__ray_ready__()` future which, once resolved,
|
||||
will trigger the `TrackedActor.on_start` callback.
|
||||
"""
|
||||
started_actors = 0
|
||||
|
||||
# Iterate through all resource requests
|
||||
for resource_request in self._resource_request_to_pending_actors:
|
||||
if max_actors is not None and started_actors >= max_actors:
|
||||
break
|
||||
|
||||
# While we have resources ready and there are actors left to schedule
|
||||
while (
|
||||
self._resource_manager.has_resources_ready(resource_request)
|
||||
and self._resource_request_to_pending_actors[resource_request]
|
||||
):
|
||||
# Acquire resources for actor
|
||||
acquired_resources = self._resource_manager.acquire_resources(
|
||||
resource_request
|
||||
)
|
||||
assert acquired_resources
|
||||
|
||||
# Get tracked actor to start
|
||||
candidate_actors = self._resource_request_to_pending_actors[
|
||||
resource_request
|
||||
]
|
||||
assert candidate_actors
|
||||
|
||||
tracked_actor = candidate_actors.pop(0)
|
||||
|
||||
# Get actor class and arguments
|
||||
actor_cls, kwargs, _ = self._pending_actors_to_attrs.pop(tracked_actor)
|
||||
|
||||
if not isinstance(actor_cls, ray.actor.ActorClass):
|
||||
actor_cls = ray.remote(actor_cls)
|
||||
|
||||
# Associate to acquired resources
|
||||
[remote_actor_cls] = acquired_resources.annotate_remote_entities(
|
||||
[actor_cls]
|
||||
)
|
||||
|
||||
# Start Ray actor
|
||||
actor = remote_actor_cls.remote(**kwargs)
|
||||
|
||||
# Track
|
||||
self._live_actors_to_ray_actors_resources[tracked_actor] = (
|
||||
actor,
|
||||
acquired_resources,
|
||||
)
|
||||
self._live_resource_cache = None
|
||||
|
||||
# Schedule ready future
|
||||
future = actor.__ray_ready__.remote()
|
||||
|
||||
self._tracked_actors_to_state_futures[tracked_actor].add(future)
|
||||
|
||||
# We need to create the callbacks in a function so tracked_actors
|
||||
# are captured correctly.
|
||||
def create_callbacks(
|
||||
tracked_actor: TrackedActor, future: ray.ObjectRef
|
||||
):
|
||||
def on_actor_start(result: Any):
|
||||
self._actor_start_resolved(
|
||||
tracked_actor=tracked_actor, future=future
|
||||
)
|
||||
|
||||
def on_error(exception: Exception):
|
||||
self._actor_start_failed(
|
||||
tracked_actor=tracked_actor, exception=exception
|
||||
)
|
||||
|
||||
return on_actor_start, on_error
|
||||
|
||||
on_actor_start, on_error = create_callbacks(
|
||||
tracked_actor=tracked_actor, future=future
|
||||
)
|
||||
|
||||
self._actor_state_events.track_future(
|
||||
future=future,
|
||||
on_result=on_actor_start,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
self._enqueue_cached_actor_tasks(tracked_actor=tracked_actor)
|
||||
|
||||
started_actors += 1
|
||||
|
||||
return started_actors
|
||||
|
||||
def _enqueue_cached_actor_tasks(self, tracked_actor: TrackedActor):
|
||||
assert tracked_actor in self._live_actors_to_ray_actors_resources
|
||||
|
||||
# Enqueue cached futures
|
||||
cached_tasks = self._pending_actors_to_enqueued_actor_tasks.pop(
|
||||
tracked_actor, []
|
||||
)
|
||||
for tracked_actor_task, method_name, args, kwargs in cached_tasks:
|
||||
self._schedule_tracked_actor_task(
|
||||
tracked_actor_task=tracked_actor_task,
|
||||
method_name=method_name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
def _try_kill_actor(self) -> bool:
|
||||
"""Try to kill actor scheduled for termination."""
|
||||
if not self._live_actors_to_kill:
|
||||
return False
|
||||
|
||||
tracked_actor = self._live_actors_to_kill.pop()
|
||||
|
||||
# Remove from tracked actors
|
||||
(
|
||||
ray_actor,
|
||||
acquired_resources,
|
||||
) = self._live_actors_to_ray_actors_resources[tracked_actor]
|
||||
|
||||
# Hard kill if requested
|
||||
ray.kill(ray_actor)
|
||||
|
||||
self._cleanup_actor_futures(tracked_actor)
|
||||
|
||||
self._actor_stop_resolved(tracked_actor)
|
||||
|
||||
return True
|
||||
|
||||
def _cleanup_actor(self, tracked_actor: TrackedActor):
|
||||
self._cleanup_actor_futures(tracked_actor)
|
||||
|
||||
# Remove from tracked actors
|
||||
(
|
||||
ray_actor,
|
||||
acquired_resources,
|
||||
) = self._live_actors_to_ray_actors_resources.pop(tracked_actor)
|
||||
self._live_resource_cache = None
|
||||
|
||||
# Return resources
|
||||
self._resource_manager.free_resources(acquired_resource=acquired_resources)
|
||||
|
||||
@property
|
||||
def all_actors(self) -> List[TrackedActor]:
|
||||
"""Return all ``TrackedActor`` objects managed by this manager instance."""
|
||||
return self.live_actors + self.pending_actors
|
||||
|
||||
@property
|
||||
def live_actors(self) -> List[TrackedActor]:
|
||||
"""Return all ``TrackedActor`` objects that are currently alive."""
|
||||
return list(self._live_actors_to_ray_actors_resources)
|
||||
|
||||
@property
|
||||
def pending_actors(self) -> List[TrackedActor]:
|
||||
"""Return all ``TrackedActor`` objects that are currently pending."""
|
||||
return list(self._pending_actors_to_attrs)
|
||||
|
||||
@property
|
||||
def num_live_actors(self):
|
||||
"""Return number of started actors."""
|
||||
return len(self.live_actors)
|
||||
|
||||
@property
|
||||
def num_pending_actors(self) -> int:
|
||||
"""Return number of pending (not yet started) actors."""
|
||||
return len(self.pending_actors)
|
||||
|
||||
@property
|
||||
def num_total_actors(self):
|
||||
"""Return number of total actors."""
|
||||
return len(self.all_actors)
|
||||
|
||||
@property
|
||||
def num_actor_tasks(self):
|
||||
"""Return number of pending tasks"""
|
||||
return self._actor_task_events.num_futures
|
||||
|
||||
def get_live_actors_resources(self):
|
||||
if self._live_resource_cache:
|
||||
return self._live_resource_cache
|
||||
|
||||
counter = Counter()
|
||||
for _, acq in self._live_actors_to_ray_actors_resources.values():
|
||||
for bdl in acq.resource_request.bundles:
|
||||
counter.update(bdl)
|
||||
self._live_resource_cache = dict(counter)
|
||||
return self._live_resource_cache
|
||||
|
||||
def add_actor(
|
||||
self,
|
||||
cls: Union[Type, ray.actor.ActorClass],
|
||||
kwargs: Dict[str, Any],
|
||||
resource_request: ResourceRequest,
|
||||
*,
|
||||
on_start: Optional[Callable[[TrackedActor], None]] = None,
|
||||
on_stop: Optional[Callable[[TrackedActor], None]] = None,
|
||||
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
|
||||
) -> TrackedActor:
|
||||
"""Add an actor to be tracked.
|
||||
|
||||
This method will request resources to start the actor. Once the resources
|
||||
are available, the actor will be started and the
|
||||
:meth:`TrackedActor.on_start
|
||||
<ray.air.execution._internal.tracked_actor.TrackedActor.on_start>` callback
|
||||
will be invoked.
|
||||
|
||||
Args:
|
||||
cls: Actor class to schedule.
|
||||
kwargs: Keyword arguments to pass to actor class on construction.
|
||||
resource_request: Resources required to start the actor.
|
||||
on_start: Callback to invoke when the actor started.
|
||||
on_stop: Callback to invoke when the actor stopped.
|
||||
on_error: Callback to invoke when the actor failed.
|
||||
|
||||
Returns:
|
||||
Tracked actor object to reference actor in subsequent API calls.
|
||||
|
||||
"""
|
||||
tracked_actor = TrackedActor(
|
||||
uuid.uuid4().int, on_start=on_start, on_stop=on_stop, on_error=on_error
|
||||
)
|
||||
|
||||
self._pending_actors_to_attrs[tracked_actor] = cls, kwargs, resource_request
|
||||
self._resource_request_to_pending_actors[resource_request].append(tracked_actor)
|
||||
|
||||
self._resource_manager.request_resources(resource_request=resource_request)
|
||||
|
||||
return tracked_actor
|
||||
|
||||
def remove_actor(
|
||||
self,
|
||||
tracked_actor: TrackedActor,
|
||||
kill: bool = False,
|
||||
stop_future: Optional[ray.ObjectRef] = None,
|
||||
) -> bool:
|
||||
"""Remove a tracked actor.
|
||||
|
||||
If the actor has already been started, this will stop the actor. This will
|
||||
trigger the :meth:`TrackedActor.on_stop
|
||||
<ray.air.execution._internal.tracked_actor.TrackedActor.on_stop>`
|
||||
callback once the actor stopped.
|
||||
|
||||
If the actor has only been requested, but not started, yet, this will cancel
|
||||
the actor request. This will not trigger any callback.
|
||||
|
||||
If ``kill=True``, this will use ``ray.kill()`` to forcefully terminate the
|
||||
actor. Otherwise, graceful actor deconstruction will be scheduled after
|
||||
all currently tracked futures are resolved.
|
||||
|
||||
This method returns a boolean, indicating if a stop future is tracked and
|
||||
the ``on_stop`` callback will be invoked. If the actor has been alive,
|
||||
this will be ``True``. If the actor hasn't been scheduled, yet, or failed
|
||||
(and triggered the ``on_error`` callback), this will be ``False``.
|
||||
|
||||
Args:
|
||||
tracked_actor: Tracked actor to be removed.
|
||||
kill: If set, will forcefully terminate the actor instead of gracefully
|
||||
scheduling termination.
|
||||
stop_future: If set, use this future to track actor termination.
|
||||
Otherwise, schedule a ``__ray_terminate__`` future.
|
||||
|
||||
Returns:
|
||||
Boolean indicating if the actor was previously alive, and thus whether
|
||||
a callback will be invoked once it is terminated.
|
||||
|
||||
"""
|
||||
if tracked_actor.actor_id in self._failed_actor_ids:
|
||||
logger.debug(
|
||||
f"Tracked actor already failed, no need to remove: {tracked_actor}"
|
||||
)
|
||||
return False
|
||||
elif tracked_actor in self._live_actors_to_ray_actors_resources:
|
||||
# Ray actor is running.
|
||||
|
||||
if not kill:
|
||||
# Schedule __ray_terminate__ future
|
||||
ray_actor, _ = self._live_actors_to_ray_actors_resources[tracked_actor]
|
||||
|
||||
# Clear state futures here to avoid resolving __ray_ready__ futures
|
||||
for future in list(
|
||||
self._tracked_actors_to_state_futures[tracked_actor]
|
||||
):
|
||||
self._actor_state_events.discard_future(future)
|
||||
self._tracked_actors_to_state_futures[tracked_actor].remove(future)
|
||||
|
||||
# If the __ray_ready__ future hasn't resolved yet, but we already
|
||||
# scheduled the actor via Actor.remote(), we just want to stop
|
||||
# it but not trigger any callbacks. This is in accordance with
|
||||
# the contract defined in the docstring.
|
||||
tracked_actor._on_start = None
|
||||
tracked_actor._on_stop = None
|
||||
tracked_actor._on_error = None
|
||||
|
||||
def on_actor_stop(*args, **kwargs):
|
||||
self._actor_stop_resolved(tracked_actor=tracked_actor)
|
||||
|
||||
if stop_future:
|
||||
# If the stop future was schedule via the actor manager,
|
||||
# discard (track it as state future instead).
|
||||
self._actor_task_events.discard_future(stop_future)
|
||||
else:
|
||||
stop_future = ray_actor.__ray_terminate__.remote()
|
||||
|
||||
self._actor_state_events.track_future(
|
||||
future=stop_future,
|
||||
on_result=on_actor_stop,
|
||||
on_error=on_actor_stop,
|
||||
)
|
||||
|
||||
self._tracked_actors_to_state_futures[tracked_actor].add(stop_future)
|
||||
else:
|
||||
# kill = True
|
||||
self._live_actors_to_kill.add(tracked_actor)
|
||||
|
||||
return True
|
||||
|
||||
elif tracked_actor in self._pending_actors_to_attrs:
|
||||
# Actor is pending, stop
|
||||
_, _, resource_request = self._pending_actors_to_attrs.pop(tracked_actor)
|
||||
self._resource_request_to_pending_actors[resource_request].remove(
|
||||
tracked_actor
|
||||
)
|
||||
self._resource_manager.cancel_resource_request(
|
||||
resource_request=resource_request
|
||||
)
|
||||
return False
|
||||
else:
|
||||
raise ValueError(f"Unknown tracked actor: {tracked_actor}")
|
||||
|
||||
def is_actor_started(self, tracked_actor: TrackedActor) -> bool:
|
||||
"""Returns True if the actor has been started.
|
||||
|
||||
Args:
|
||||
tracked_actor: Tracked actor object.
|
||||
|
||||
Returns:
|
||||
True if the actor has been started, False otherwise.
|
||||
"""
|
||||
return (
|
||||
tracked_actor in self._live_actors_to_ray_actors_resources
|
||||
and tracked_actor.actor_id not in self._failed_actor_ids
|
||||
)
|
||||
|
||||
def is_actor_failed(self, tracked_actor: TrackedActor) -> bool:
|
||||
return tracked_actor.actor_id in self._failed_actor_ids
|
||||
|
||||
def get_actor_resources(
|
||||
self, tracked_actor: TrackedActor
|
||||
) -> Optional[AcquiredResources]:
|
||||
"""Returns the acquired resources of an actor that has been started.
|
||||
|
||||
This will return ``None`` if the actor has not been started, yet.
|
||||
|
||||
Args:
|
||||
tracked_actor: Tracked actor object.
|
||||
|
||||
Returns:
|
||||
The acquired resources of the actor, or ``None`` if the actor has not
|
||||
been started yet.
|
||||
"""
|
||||
if not self.is_actor_started(tracked_actor):
|
||||
return None
|
||||
|
||||
return self._live_actors_to_ray_actors_resources[tracked_actor][1]
|
||||
|
||||
def schedule_actor_task(
|
||||
self,
|
||||
tracked_actor: TrackedActor,
|
||||
method_name: str,
|
||||
args: Optional[Tuple] = None,
|
||||
kwargs: Optional[Dict] = None,
|
||||
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
|
||||
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
|
||||
_return_future: bool = False,
|
||||
) -> Optional[ray.ObjectRef]:
|
||||
"""Schedule and track a task on an actor.
|
||||
|
||||
This method will schedule a remote task ``method_name`` on the
|
||||
``tracked_actor``.
|
||||
|
||||
This method accepts two optional callbacks that will be invoked when
|
||||
their respective events are triggered.
|
||||
|
||||
The ``on_result`` callback is triggered when a task resolves successfully.
|
||||
It should accept two arguments: The actor for which the
|
||||
task resolved, and the result received from the remote call.
|
||||
|
||||
The ``on_error`` callback is triggered when a task fails.
|
||||
It should accept two arguments: The actor for which the
|
||||
task threw an error, and the exception.
|
||||
|
||||
Args:
|
||||
tracked_actor: Actor to schedule task on.
|
||||
method_name: Remote method name to invoke on the actor. If this is
|
||||
e.g. ``foo``, then ``actor.foo.remote(*args, **kwargs)`` will be
|
||||
scheduled.
|
||||
args: Arguments to pass to the task.
|
||||
kwargs: Keyword arguments to pass to the task.
|
||||
on_result: Callback to invoke when the task resolves.
|
||||
on_error: Callback to invoke when the task fails.
|
||||
_return_future: If True, return the scheduled task's ``ObjectRef`` for
|
||||
advanced callers. Defaults to False.
|
||||
|
||||
Raises:
|
||||
ValueError: If the ``tracked_actor`` is not managed by this event manager.
|
||||
|
||||
Returns:
|
||||
The scheduled task's ``ObjectRef`` if ``_return_future`` is True,
|
||||
otherwise ``None``.
|
||||
"""
|
||||
args = args or tuple()
|
||||
kwargs = kwargs or {}
|
||||
|
||||
if tracked_actor.actor_id in self._failed_actor_ids:
|
||||
return
|
||||
|
||||
tracked_actor_task = TrackedActorTask(
|
||||
tracked_actor=tracked_actor, on_result=on_result, on_error=on_error
|
||||
)
|
||||
|
||||
if tracked_actor not in self._live_actors_to_ray_actors_resources:
|
||||
# Actor is not started, yet
|
||||
if tracked_actor not in self._pending_actors_to_attrs:
|
||||
raise ValueError(
|
||||
f"Tracked actor is not managed by this event manager: "
|
||||
f"{tracked_actor}"
|
||||
)
|
||||
|
||||
# Cache tasks for future execution
|
||||
self._pending_actors_to_enqueued_actor_tasks[tracked_actor].append(
|
||||
(tracked_actor_task, method_name, args, kwargs)
|
||||
)
|
||||
else:
|
||||
res = self._schedule_tracked_actor_task(
|
||||
tracked_actor_task=tracked_actor_task,
|
||||
method_name=method_name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
_return_future=_return_future,
|
||||
)
|
||||
if _return_future:
|
||||
return res[1]
|
||||
|
||||
def _schedule_tracked_actor_task(
|
||||
self,
|
||||
tracked_actor_task: TrackedActorTask,
|
||||
method_name: str,
|
||||
*,
|
||||
args: Optional[Tuple] = None,
|
||||
kwargs: Optional[Dict] = None,
|
||||
_return_future: bool = False,
|
||||
) -> Union[TrackedActorTask, Tuple[TrackedActorTask, ray.ObjectRef]]:
|
||||
tracked_actor = tracked_actor_task._tracked_actor
|
||||
ray_actor, _ = self._live_actors_to_ray_actors_resources[tracked_actor]
|
||||
|
||||
try:
|
||||
remote_fn = getattr(ray_actor, method_name)
|
||||
except AttributeError as e:
|
||||
raise AttributeError(
|
||||
f"Remote function `{method_name}()` does not exist for this actor."
|
||||
) from e
|
||||
|
||||
def on_result(result: Any):
|
||||
self._actor_task_resolved(
|
||||
tracked_actor_task=tracked_actor_task, result=result
|
||||
)
|
||||
|
||||
def on_error(exception: Exception):
|
||||
self._actor_task_failed(
|
||||
tracked_actor_task=tracked_actor_task, exception=exception
|
||||
)
|
||||
|
||||
future = remote_fn.remote(*args, **kwargs)
|
||||
|
||||
self._actor_task_events.track_future(
|
||||
future=future, on_result=on_result, on_error=on_error
|
||||
)
|
||||
|
||||
self._tracked_actors_to_task_futures[tracked_actor].add(future)
|
||||
|
||||
if _return_future:
|
||||
return tracked_actor_task, future
|
||||
|
||||
return tracked_actor_task
|
||||
|
||||
def schedule_actor_tasks(
|
||||
self,
|
||||
tracked_actors: List[TrackedActor],
|
||||
method_name: str,
|
||||
*,
|
||||
args: Optional[Union[Tuple, List[Tuple]]] = None,
|
||||
kwargs: Optional[Union[Dict, List[Dict]]] = None,
|
||||
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
|
||||
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
|
||||
) -> None:
|
||||
"""Schedule and track tasks on a list of actors.
|
||||
|
||||
This method will schedule a remote task ``method_name`` on all
|
||||
``tracked_actors``.
|
||||
|
||||
``args`` and ``kwargs`` can be a single tuple/dict, in which case the same
|
||||
(keyword) arguments are passed to all actors. If a list is passed instead,
|
||||
they are mapped to the respective actors. In that case, the list of
|
||||
(keyword) arguments must be the same length as the list of actors.
|
||||
|
||||
This method accepts two optional callbacks that will be invoked when
|
||||
their respective events are triggered.
|
||||
|
||||
The ``on_result`` callback is triggered when a task resolves successfully.
|
||||
It should accept two arguments: The actor for which the
|
||||
task resolved, and the result received from the remote call.
|
||||
|
||||
The ``on_error`` callback is triggered when a task fails.
|
||||
It should accept two arguments: The actor for which the
|
||||
task threw an error, and the exception.
|
||||
|
||||
Args:
|
||||
tracked_actors: List of actors to schedule tasks on.
|
||||
method_name: Remote actor method to invoke on the actors. If this is
|
||||
e.g. ``foo``, then ``actor.foo.remote(*args, **kwargs)`` will be
|
||||
scheduled on all actors.
|
||||
args: Arguments to pass to the task.
|
||||
kwargs: Keyword arguments to pass to the task.
|
||||
on_result: Callback to invoke when the task resolves.
|
||||
on_error: Callback to invoke when the task fails.
|
||||
|
||||
"""
|
||||
if not isinstance(args, List):
|
||||
args_list = [args] * len(tracked_actors)
|
||||
else:
|
||||
if len(tracked_actors) != len(args):
|
||||
raise ValueError(
|
||||
f"Length of args must be the same as tracked_actors "
|
||||
f"list. Got `len(kwargs)={len(kwargs)}` and "
|
||||
f"`len(tracked_actors)={len(tracked_actors)}"
|
||||
)
|
||||
args_list = args
|
||||
|
||||
if not isinstance(kwargs, List):
|
||||
kwargs_list = [kwargs] * len(tracked_actors)
|
||||
else:
|
||||
if len(tracked_actors) != len(kwargs):
|
||||
raise ValueError(
|
||||
f"Length of kwargs must be the same as tracked_actors "
|
||||
f"list. Got `len(args)={len(args)}` and "
|
||||
f"`len(tracked_actors)={len(tracked_actors)}"
|
||||
)
|
||||
kwargs_list = kwargs
|
||||
|
||||
for tracked_actor, args, kwargs in zip(tracked_actors, args_list, kwargs_list):
|
||||
self.schedule_actor_task(
|
||||
tracked_actor=tracked_actor,
|
||||
method_name=method_name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
on_result=on_result,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
def clear_actor_task_futures(self, tracked_actor: TrackedActor):
|
||||
"""Discard all actor task futures from a tracked actor."""
|
||||
futures = self._tracked_actors_to_task_futures.pop(tracked_actor, [])
|
||||
for future in futures:
|
||||
self._actor_task_events.discard_future(future)
|
||||
|
||||
def _cleanup_actor_futures(self, tracked_actor: TrackedActor):
|
||||
# Remove all actor task futures
|
||||
self.clear_actor_task_futures(tracked_actor=tracked_actor)
|
||||
|
||||
# Remove all actor state futures
|
||||
futures = self._tracked_actors_to_state_futures.pop(tracked_actor, [])
|
||||
for future in futures:
|
||||
self._actor_state_events.discard_future(future)
|
||||
|
||||
def cleanup(self):
|
||||
for (
|
||||
actor,
|
||||
acquired_resources,
|
||||
) in self._live_actors_to_ray_actors_resources.values():
|
||||
ray.kill(actor)
|
||||
self._resource_manager.free_resources(acquired_resources)
|
||||
|
||||
for (
|
||||
resource_request,
|
||||
pending_actors,
|
||||
) in self._resource_request_to_pending_actors.items():
|
||||
for i in range(len(pending_actors)):
|
||||
self._resource_manager.cancel_resource_request(resource_request)
|
||||
|
||||
self._resource_manager.clear()
|
||||
|
||||
self.__init__(resource_manager=self._resource_manager)
|
||||
@@ -0,0 +1,93 @@
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
|
||||
class Barrier:
|
||||
"""Barrier to collect results and process them in bulk.
|
||||
|
||||
A barrier can be used to collect multiple results and process them in bulk once
|
||||
a certain count or a timeout is reached.
|
||||
|
||||
For instance, if ``max_results=N``, the ``on_completion`` callback will be
|
||||
invoked once :meth:`arrive` has been called ``N`` times.
|
||||
|
||||
The completion callback will only be invoked once, even if more results
|
||||
arrive after completion. The collected results can be resetted
|
||||
with :meth:`reset`, after which the callback may be invoked again.
|
||||
|
||||
The completion callback should expect one argument, which is the barrier
|
||||
object that completed.
|
||||
|
||||
Args:
|
||||
max_results: Maximum number of results to collect before a call to
|
||||
:meth:`wait` resolves or the :meth:`on_completion` callback is invoked.
|
||||
on_completion: Callback to invoke when ``max_results`` results
|
||||
arrived at the barrier.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_results: int,
|
||||
*,
|
||||
on_completion: Optional[Callable[["Barrier"], None]] = None,
|
||||
):
|
||||
self._max_results = max_results
|
||||
|
||||
# on_completion callback
|
||||
self._completed = False
|
||||
self._on_completion = on_completion
|
||||
|
||||
# Collect received results
|
||||
self._results: List[Tuple[Any]] = []
|
||||
|
||||
def arrive(self, *data: Any):
|
||||
"""Notify barrier that a result successfully arrived.
|
||||
|
||||
This will count against the ``max_results`` limit. The received result
|
||||
will be included in a call to :meth:`get_results`.
|
||||
|
||||
Args:
|
||||
*data: Result data to be cached. Can be obtained via :meth:`get_results`.
|
||||
|
||||
"""
|
||||
if len(data) == 1:
|
||||
data = data[0]
|
||||
|
||||
self._results.append(data)
|
||||
self._check_completion()
|
||||
|
||||
def _check_completion(self):
|
||||
if self._completed:
|
||||
# Already fired completion callback
|
||||
return
|
||||
|
||||
if self.num_results >= self._max_results:
|
||||
# Barrier is complete
|
||||
self._completed = True
|
||||
|
||||
if self._on_completion:
|
||||
self._on_completion(self)
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
"""Returns True if the barrier is completed."""
|
||||
return self._completed
|
||||
|
||||
@property
|
||||
def num_results(self) -> int:
|
||||
"""Number of received (successful) results."""
|
||||
return len(self._results)
|
||||
|
||||
def get_results(self) -> List[Tuple[Any]]:
|
||||
"""Return list of received results."""
|
||||
return self._results
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset barrier, removing all received results.
|
||||
|
||||
Resetting the barrier will reset the completion status. When ``max_results``
|
||||
is set and enough new events arrive after resetting, the
|
||||
:meth:`on_completion` callback will be invoked again.
|
||||
"""
|
||||
self._completed = False
|
||||
self._results = []
|
||||
@@ -0,0 +1,148 @@
|
||||
import random
|
||||
from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, Union
|
||||
|
||||
import ray
|
||||
|
||||
_ResultCallback = Callable[[Any], None]
|
||||
_ErrorCallback = Callable[[Exception], None]
|
||||
|
||||
|
||||
class RayEventManager:
|
||||
"""Event manager for Ray futures.
|
||||
|
||||
The event manager can be used to track futures and invoke callbacks when
|
||||
they resolve.
|
||||
|
||||
Futures are tracked with :meth:`track_future`. Future can then be awaited with
|
||||
:meth:`wait`. When futures successfully resolve, they trigger an optional
|
||||
``on_result`` callback that can be passed to :meth:`track_future`. If they
|
||||
fail, they trigger an optional ``on_error`` callback.
|
||||
|
||||
Args:
|
||||
shuffle_futures: If True, futures will be shuffled before awaited. This
|
||||
will avoid implicit prioritization of futures within Ray.
|
||||
"""
|
||||
|
||||
def __init__(self, shuffle_futures: bool = True):
|
||||
self._shuffle_futures = shuffle_futures
|
||||
|
||||
# Map of futures to callbacks (result, error)
|
||||
self._tracked_futures: Dict[
|
||||
ray.ObjectRef, Tuple[Optional[_ResultCallback], Optional[_ErrorCallback]]
|
||||
] = {}
|
||||
|
||||
def track_future(
|
||||
self,
|
||||
future: ray.ObjectRef,
|
||||
on_result: Optional[_ResultCallback] = None,
|
||||
on_error: Optional[_ErrorCallback] = None,
|
||||
):
|
||||
"""Track a single future and invoke callbacks on resolution.
|
||||
|
||||
Control has to be yielded to the event manager for the callbacks to
|
||||
be invoked, either via :meth:`wait` or via :meth:`resolve_future`.
|
||||
|
||||
Args:
|
||||
future: Ray future to await.
|
||||
on_result: Callback to invoke when the future resolves successfully.
|
||||
on_error: Callback to invoke when the future fails.
|
||||
|
||||
"""
|
||||
self._tracked_futures[future] = (on_result, on_error)
|
||||
|
||||
def track_futures(
|
||||
self,
|
||||
futures: Iterable[ray.ObjectRef],
|
||||
on_result: Optional[_ResultCallback] = None,
|
||||
on_error: Optional[_ErrorCallback] = None,
|
||||
):
|
||||
"""Track multiple futures and invoke callbacks on resolution.
|
||||
|
||||
Control has to be yielded to the event manager for the callbacks to
|
||||
be invoked, either via :meth:`wait` or via :meth:`resolve_future`.
|
||||
|
||||
Args:
|
||||
futures: Ray futures to await.
|
||||
on_result: Callback to invoke when the future resolves successfully.
|
||||
on_error: Callback to invoke when the future fails.
|
||||
|
||||
"""
|
||||
for future in futures:
|
||||
self.track_future(future, on_result=on_result, on_error=on_error)
|
||||
|
||||
def discard_future(self, future: ray.ObjectRef):
|
||||
"""Remove future from tracking.
|
||||
|
||||
The future will not be awaited anymore, and it will not trigger any callbacks.
|
||||
|
||||
Args:
|
||||
future: Ray futures to discard.
|
||||
"""
|
||||
self._tracked_futures.pop(future, None)
|
||||
|
||||
def get_futures(self) -> Set[ray.ObjectRef]:
|
||||
"""Get futures tracked by the event manager."""
|
||||
return set(self._tracked_futures)
|
||||
|
||||
@property
|
||||
def num_futures(self) -> int:
|
||||
return len(self._tracked_futures)
|
||||
|
||||
def resolve_future(self, future: ray.ObjectRef):
|
||||
"""Resolve a single future.
|
||||
|
||||
This method will block until the future is available. It will then
|
||||
trigger the callback associated to the future and the event (success
|
||||
or error), if specified.
|
||||
|
||||
Args:
|
||||
future: Ray future to resolve.
|
||||
|
||||
"""
|
||||
try:
|
||||
on_result, on_error = self._tracked_futures.pop(future)
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f"Future {future} is not tracked by this RayEventManager"
|
||||
) from e
|
||||
|
||||
try:
|
||||
result = ray.get(future)
|
||||
except Exception as e:
|
||||
if on_error:
|
||||
on_error(e)
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
if on_result:
|
||||
on_result(result)
|
||||
|
||||
def wait(
|
||||
self,
|
||||
timeout: Optional[Union[float, int]] = None,
|
||||
num_results: Optional[int] = 1,
|
||||
):
|
||||
"""Wait up to ``timeout`` seconds for ``num_results`` futures to resolve.
|
||||
|
||||
If ``timeout=None``, this method will block until all `num_results`` futures
|
||||
resolve. If ``num_results=None``, this method will await all tracked futures.
|
||||
|
||||
For every future that resolves, the respective associated callbacks will be
|
||||
invoked.
|
||||
|
||||
Args:
|
||||
timeout: Timeout in second to wait for futures to resolve.
|
||||
num_results: Number of futures to await. If ``None``, will wait for
|
||||
all tracked futures to resolve.
|
||||
|
||||
"""
|
||||
futures = list(self.get_futures())
|
||||
|
||||
if self._shuffle_futures:
|
||||
random.shuffle(futures)
|
||||
|
||||
num_results = num_results or len(futures)
|
||||
|
||||
ready, _ = ray.wait(list(futures), timeout=timeout, num_returns=num_results)
|
||||
for future in ready:
|
||||
self.resolve_future(future)
|
||||
@@ -0,0 +1,62 @@
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class TrackedActor:
|
||||
"""Actor tracked by an actor manager.
|
||||
|
||||
This object is used to reference a Ray actor on an actor manager
|
||||
|
||||
Existence of this object does not mean that the Ray actor has already been started.
|
||||
Actor state can be inquired from the actor manager tracking the Ray actor.
|
||||
|
||||
Note:
|
||||
Objects of this class are returned by the :class:`RayActorManager`.
|
||||
This class should not be instantiated manually.
|
||||
|
||||
Attributes:
|
||||
actor_id: ID for identification of the actor within the actor manager. This
|
||||
ID is not related to the Ray actor ID.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actor_id: int,
|
||||
on_start: Optional[Callable[["TrackedActor"], None]] = None,
|
||||
on_stop: Optional[Callable[["TrackedActor"], None]] = None,
|
||||
on_error: Optional[Callable[["TrackedActor", Exception], None]] = None,
|
||||
):
|
||||
"""Initialize the tracked actor.
|
||||
|
||||
Args:
|
||||
actor_id: ID for identification of the actor within the actor manager.
|
||||
on_start: Callback to invoke when the actor started.
|
||||
on_stop: Callback to invoke when the actor stopped.
|
||||
on_error: Callback to invoke when the actor failed.
|
||||
"""
|
||||
self.actor_id = actor_id
|
||||
self._on_start = on_start
|
||||
self._on_stop = on_stop
|
||||
self._on_error = on_error
|
||||
|
||||
def set_on_start(self, on_start: Optional[Callable[["TrackedActor"], None]]):
|
||||
self._on_start = on_start
|
||||
|
||||
def set_on_stop(self, on_stop: Optional[Callable[["TrackedActor"], None]]):
|
||||
self._on_stop = on_stop
|
||||
|
||||
def set_on_error(
|
||||
self, on_error: Optional[Callable[["TrackedActor", Exception], None]]
|
||||
):
|
||||
self._on_error = on_error
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TrackedActor {self.actor_id}>"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return self.actor_id == other.actor_id
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.actor_id)
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ray.air.execution._internal.tracked_actor import TrackedActor
|
||||
|
||||
|
||||
class TrackedActorTask:
|
||||
"""Actor task tracked by a Ray event manager.
|
||||
|
||||
This container class is used to define callbacks to be invoked when
|
||||
the task resolves, errors, or times out.
|
||||
|
||||
Note:
|
||||
Objects of this class are returned by the :class:`RayActorManager`.
|
||||
This class should not be instantiated manually.
|
||||
|
||||
Args:
|
||||
tracked_actor: Tracked actor object this task is scheduled on.
|
||||
on_result: Callback to invoke when the task resolves.
|
||||
on_error: Callback to invoke when the task fails.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
tracked_futures = actor_manager.schedule_actor_tasks(
|
||||
actor_manager.live_actors,
|
||||
"foo",
|
||||
on_result=lambda actor, result: print(result)
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tracked_actor: TrackedActor,
|
||||
on_result: Optional[Callable[[TrackedActor, Any], None]] = None,
|
||||
on_error: Optional[Callable[[TrackedActor, Exception], None]] = None,
|
||||
):
|
||||
self._tracked_actor = tracked_actor
|
||||
|
||||
self._on_result = on_result
|
||||
self._on_error = on_error
|
||||
@@ -0,0 +1,12 @@
|
||||
from ray.air.execution.resources.fixed import FixedResourceManager
|
||||
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
|
||||
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
|
||||
from ray.air.execution.resources.resource_manager import ResourceManager
|
||||
|
||||
__all__ = [
|
||||
"ResourceRequest",
|
||||
"AcquiredResources",
|
||||
"ResourceManager",
|
||||
"FixedResourceManager",
|
||||
"PlacementGroupResourceManager",
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import ray
|
||||
from ray import SCRIPT_MODE
|
||||
from ray.air.execution.resources.request import (
|
||||
AcquiredResources,
|
||||
RemoteRayEntity,
|
||||
ResourceRequest,
|
||||
)
|
||||
from ray.air.execution.resources.resource_manager import ResourceManager
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
# Avoid numerical errors by multiplying and subtracting with this number.
|
||||
# Compare: 0.99 - 0.33 = 0.65999... vs (0.99 * 1000 - 0.33 * 1000) / 1000 = 0.66
|
||||
_DIGITS = 100000
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class FixedAcquiredResources(AcquiredResources):
|
||||
bundles: List[Dict[str, float]]
|
||||
|
||||
def _annotate_remote_entity(
|
||||
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
|
||||
) -> RemoteRayEntity:
|
||||
bundle = bundle.copy()
|
||||
num_cpus = bundle.pop("CPU", 0)
|
||||
num_gpus = bundle.pop("GPU", 0)
|
||||
memory = bundle.pop("memory", 0.0)
|
||||
|
||||
return entity.options(
|
||||
num_cpus=num_cpus,
|
||||
num_gpus=num_gpus,
|
||||
memory=memory,
|
||||
resources=bundle,
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class FixedResourceManager(ResourceManager):
|
||||
"""Fixed budget based resource manager.
|
||||
|
||||
This resource manager keeps track of a fixed set of resources. When resources
|
||||
are acquired, they are subtracted from the budget. When resources are freed,
|
||||
they are added back to the budget.
|
||||
|
||||
The resource manager still requires resources to be requested before they become
|
||||
available. However, because the resource requests are virtual, this will not
|
||||
trigger autoscaling.
|
||||
|
||||
Additionally, resources are not reserved on request, only on acquisition. Thus,
|
||||
acquiring a resource can change the availability of other requests. Note that
|
||||
this behavior may be changed in future implementations.
|
||||
|
||||
The fixed resource manager does not support placement strategies. Using
|
||||
``STRICT_SPREAD`` will result in an error. ``STRICT_PACK`` will succeed only
|
||||
within a placement group bundle. All other placement group arguments will be
|
||||
ignored.
|
||||
|
||||
Args:
|
||||
total_resources: Budget of resources to manage. Defaults to all available
|
||||
resources in the current task or all cluster resources (if outside a task).
|
||||
|
||||
"""
|
||||
|
||||
_resource_cls: AcquiredResources = FixedAcquiredResources
|
||||
|
||||
def __init__(self, total_resources: Optional[Dict[str, float]] = None):
|
||||
rtc = ray.get_runtime_context()
|
||||
|
||||
if not total_resources:
|
||||
if rtc.worker.mode in {None, SCRIPT_MODE}:
|
||||
total_resources = ray.cluster_resources()
|
||||
else:
|
||||
total_resources = rtc.get_assigned_resources()
|
||||
|
||||
# If we are in a placement group, all of our resources will be in a bundle
|
||||
# and thus fulfill requirements of STRICT_PACK - but only if child tasks
|
||||
# are captured by the pg.
|
||||
self._allow_strict_pack = (
|
||||
ray.util.get_current_placement_group() is not None
|
||||
and rtc.should_capture_child_tasks_in_placement_group
|
||||
)
|
||||
|
||||
self._total_resources = total_resources
|
||||
self._requested_resources = []
|
||||
self._used_resources = []
|
||||
|
||||
@property
|
||||
def _available_resources(self) -> Dict[str, float]:
|
||||
available_resources = self._total_resources.copy()
|
||||
|
||||
for used_resources in self._used_resources:
|
||||
all_resources = used_resources.required_resources
|
||||
for k, v in all_resources.items():
|
||||
available_resources[k] = (
|
||||
available_resources[k] * _DIGITS - v * _DIGITS
|
||||
) / _DIGITS
|
||||
return available_resources
|
||||
|
||||
def request_resources(self, resource_request: ResourceRequest):
|
||||
if resource_request.strategy == "STRICT_SPREAD" or (
|
||||
not self._allow_strict_pack and resource_request.strategy == "STRICT_PACK"
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Requested a resource with placement strategy "
|
||||
f"{resource_request.strategy}, but this cannot be fulfilled by a "
|
||||
f"FixedResourceManager. In a nested setting, please set the inner "
|
||||
f"placement strategy to be less restrictive (i.e. no STRICT_ strategy)."
|
||||
)
|
||||
|
||||
self._requested_resources.append(resource_request)
|
||||
|
||||
def cancel_resource_request(self, resource_request: ResourceRequest):
|
||||
self._requested_resources.remove(resource_request)
|
||||
|
||||
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
|
||||
if resource_request not in self._requested_resources:
|
||||
return False
|
||||
|
||||
available_resources = self._available_resources
|
||||
all_resources = resource_request.required_resources
|
||||
for k, v in all_resources.items():
|
||||
if available_resources.get(k, 0.0) < v:
|
||||
return False
|
||||
return True
|
||||
|
||||
def acquire_resources(
|
||||
self, resource_request: ResourceRequest
|
||||
) -> Optional[AcquiredResources]:
|
||||
if not self.has_resources_ready(resource_request):
|
||||
return None
|
||||
|
||||
self._used_resources.append(resource_request)
|
||||
return self._resource_cls(
|
||||
bundles=resource_request.bundles, resource_request=resource_request
|
||||
)
|
||||
|
||||
def free_resources(self, acquired_resource: AcquiredResources):
|
||||
resources = acquired_resource.resource_request
|
||||
self._used_resources.remove(resources)
|
||||
|
||||
def clear(self):
|
||||
# Reset internal state
|
||||
self._requested_resources = []
|
||||
self._used_resources = []
|
||||
@@ -0,0 +1,214 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
import ray
|
||||
from ray.air.execution.resources.request import (
|
||||
AcquiredResources,
|
||||
RemoteRayEntity,
|
||||
ResourceRequest,
|
||||
)
|
||||
from ray.air.execution.resources.resource_manager import ResourceManager
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
from ray.util.placement_group import PlacementGroup, remove_placement_group
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class PlacementGroupAcquiredResources(AcquiredResources):
|
||||
placement_group: PlacementGroup
|
||||
|
||||
def _annotate_remote_entity(
|
||||
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
|
||||
) -> RemoteRayEntity:
|
||||
bundle = bundle.copy()
|
||||
num_cpus = bundle.pop("CPU", 0)
|
||||
num_gpus = bundle.pop("GPU", 0)
|
||||
memory = bundle.pop("memory", 0.0)
|
||||
|
||||
return entity.options(
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||||
placement_group=self.placement_group,
|
||||
placement_group_bundle_index=bundle_index,
|
||||
placement_group_capture_child_tasks=True,
|
||||
),
|
||||
num_cpus=num_cpus,
|
||||
num_gpus=num_gpus,
|
||||
memory=memory,
|
||||
resources=bundle,
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PlacementGroupResourceManager(ResourceManager):
|
||||
"""Resource manager using placement groups as the resource backend.
|
||||
|
||||
This manager will use placement groups to fulfill resource requests. Requesting
|
||||
a resource will schedule the placement group. Acquiring a resource will
|
||||
return a ``PlacementGroupAcquiredResources`` that can be used to schedule
|
||||
Ray tasks and actors on the placement group. Freeing an acquired resource
|
||||
will destroy the associated placement group.
|
||||
|
||||
Ray core does not emit events when resources are available. Instead, the
|
||||
scheduling state has to be periodically updated.
|
||||
|
||||
Per default, placement group scheduling state is refreshed every time when
|
||||
resource state is inquired, but not more often than once every ``update_interval_s``
|
||||
seconds. Alternatively, staging futures can be retrieved (and awaited) with
|
||||
``get_resource_futures()`` and state update can be force with ``update_state()``.
|
||||
|
||||
Args:
|
||||
update_interval_s: Minimum interval in seconds between updating scheduling
|
||||
state of placement groups.
|
||||
|
||||
"""
|
||||
|
||||
_resource_cls: AcquiredResources = PlacementGroupAcquiredResources
|
||||
|
||||
def __init__(self, update_interval_s: float = 0.1):
|
||||
# Internally, the placement group lifecycle is like this:
|
||||
# - Resources are requested with ``request_resources()``
|
||||
# - A placement group is scheduled ("staged")
|
||||
# - A ``PlacementGroup.ready()`` future is scheduled ("staging future")
|
||||
# - We update the scheduling state when we need to
|
||||
# (e.g. when ``has_resources_ready()`` is called)
|
||||
# - When staging futures resolve, a placement group is moved from "staging"
|
||||
# to "ready"
|
||||
# - When a resource request is canceled, we remove a placement group from
|
||||
# "staging". If there are not staged placement groups
|
||||
# (because they are already "ready"), we remove one from "ready" instead.
|
||||
# - When a resource is acquired, the pg is removed from "ready" and moved
|
||||
# to "acquired"
|
||||
# - When a resource is freed, the pg is removed from "acquired" and destroyed
|
||||
|
||||
# Mapping of placement group to request
|
||||
self._pg_to_request: Dict[PlacementGroup, ResourceRequest] = {}
|
||||
|
||||
# PGs that are staged but not "ready", yet (i.e. not CREATED)
|
||||
self._request_to_staged_pgs: Dict[
|
||||
ResourceRequest, Set[PlacementGroup]
|
||||
] = defaultdict(set)
|
||||
|
||||
# PGs that are CREATED and can be used by tasks and actors
|
||||
self._request_to_ready_pgs: Dict[
|
||||
ResourceRequest, Set[PlacementGroup]
|
||||
] = defaultdict(set)
|
||||
|
||||
# Staging futures used to update internal state.
|
||||
# We keep a double mapping here for better lookup efficiency.
|
||||
self._staging_future_to_pg: Dict[ray.ObjectRef, PlacementGroup] = dict()
|
||||
self._pg_to_staging_future: Dict[PlacementGroup, ray.ObjectRef] = dict()
|
||||
|
||||
# Set of acquired PGs. We keep track of these here to make sure we
|
||||
# only free PGs that this manager managed.
|
||||
self._acquired_pgs: Set[PlacementGroup] = set()
|
||||
|
||||
# Minimum time between updates of the internal state
|
||||
self.update_interval_s = update_interval_s
|
||||
self._last_update = time.monotonic() - self.update_interval_s - 1
|
||||
|
||||
def get_resource_futures(self) -> List[ray.ObjectRef]:
|
||||
return list(self._staging_future_to_pg.keys())
|
||||
|
||||
def _maybe_update_state(self):
|
||||
now = time.monotonic()
|
||||
if now > self._last_update + self.update_interval_s:
|
||||
self.update_state()
|
||||
|
||||
def update_state(self):
|
||||
ready, not_ready = ray.wait(
|
||||
list(self._staging_future_to_pg.keys()),
|
||||
num_returns=len(self._staging_future_to_pg),
|
||||
timeout=0,
|
||||
)
|
||||
for future in ready:
|
||||
# Remove staging future
|
||||
pg = self._staging_future_to_pg.pop(future)
|
||||
self._pg_to_staging_future.pop(pg)
|
||||
# Fetch resource request
|
||||
request = self._pg_to_request[pg]
|
||||
# Remove from staging, add to ready
|
||||
self._request_to_staged_pgs[request].remove(pg)
|
||||
self._request_to_ready_pgs[request].add(pg)
|
||||
self._last_update = time.monotonic()
|
||||
|
||||
def request_resources(self, resource_request: ResourceRequest):
|
||||
pg = resource_request.to_placement_group()
|
||||
self._pg_to_request[pg] = resource_request
|
||||
self._request_to_staged_pgs[resource_request].add(pg)
|
||||
|
||||
future = pg.ready()
|
||||
self._staging_future_to_pg[future] = pg
|
||||
self._pg_to_staging_future[pg] = future
|
||||
|
||||
def cancel_resource_request(self, resource_request: ResourceRequest):
|
||||
if self._request_to_staged_pgs[resource_request]:
|
||||
pg = self._request_to_staged_pgs[resource_request].pop()
|
||||
|
||||
# PG was staging
|
||||
future = self._pg_to_staging_future.pop(pg)
|
||||
self._staging_future_to_pg.pop(future)
|
||||
|
||||
# Cancel the pg.ready task.
|
||||
# Otherwise, it will be pending node assignment forever.
|
||||
ray.cancel(future)
|
||||
else:
|
||||
# PG might be ready
|
||||
pg = self._request_to_ready_pgs[resource_request].pop()
|
||||
if not pg:
|
||||
raise RuntimeError(
|
||||
"Cannot cancel resource request: No placement group was "
|
||||
f"staged or is ready. Make sure to not cancel more resource "
|
||||
f"requests than you've created. Request: {resource_request}"
|
||||
)
|
||||
|
||||
self._pg_to_request.pop(pg)
|
||||
ray.util.remove_placement_group(pg)
|
||||
|
||||
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
|
||||
if not bool(len(self._request_to_ready_pgs[resource_request])):
|
||||
# Only update state if needed
|
||||
self._maybe_update_state()
|
||||
|
||||
return bool(len(self._request_to_ready_pgs[resource_request]))
|
||||
|
||||
def acquire_resources(
|
||||
self, resource_request: ResourceRequest
|
||||
) -> Optional[PlacementGroupAcquiredResources]:
|
||||
if not self.has_resources_ready(resource_request):
|
||||
return None
|
||||
|
||||
pg = self._request_to_ready_pgs[resource_request].pop()
|
||||
self._acquired_pgs.add(pg)
|
||||
|
||||
return self._resource_cls(placement_group=pg, resource_request=resource_request)
|
||||
|
||||
def free_resources(self, acquired_resource: PlacementGroupAcquiredResources):
|
||||
pg = acquired_resource.placement_group
|
||||
|
||||
self._acquired_pgs.remove(pg)
|
||||
remove_placement_group(pg)
|
||||
self._pg_to_request.pop(pg)
|
||||
|
||||
def clear(self):
|
||||
if not ray.is_initialized():
|
||||
return
|
||||
|
||||
for staged_pgs in self._request_to_staged_pgs.values():
|
||||
for staged_pg in staged_pgs:
|
||||
remove_placement_group(staged_pg)
|
||||
|
||||
for ready_pgs in self._request_to_ready_pgs.values():
|
||||
for ready_pg in ready_pgs:
|
||||
remove_placement_group(ready_pg)
|
||||
|
||||
for acquired_pg in self._acquired_pgs:
|
||||
remove_placement_group(acquired_pg)
|
||||
|
||||
# Reset internal state
|
||||
self.__init__(update_interval_s=self.update_interval_s)
|
||||
|
||||
def __del__(self):
|
||||
self.clear()
|
||||
@@ -0,0 +1,259 @@
|
||||
import abc
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from inspect import signature
|
||||
from typing import Dict, List, Union
|
||||
|
||||
import ray
|
||||
from ray.util import placement_group
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
RemoteRayEntity = Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass]
|
||||
|
||||
|
||||
def _sum_bundles(bundles: List[Dict[str, float]]) -> Dict[str, float]:
|
||||
"""Sum all resources in a list of resource bundles.
|
||||
|
||||
Args:
|
||||
bundles: List of resource bundles.
|
||||
|
||||
Returns:
|
||||
Dict containing all resources summed up.
|
||||
"""
|
||||
resources = {}
|
||||
for bundle in bundles:
|
||||
for k, v in bundle.items():
|
||||
resources[k] = resources.get(k, 0) + v
|
||||
return resources
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ResourceRequest:
|
||||
"""Request for resources.
|
||||
|
||||
This class is used to define a resource request. A resource request comprises one
|
||||
or more bundles of resources and instructions on the scheduling behavior.
|
||||
|
||||
The resource request can be submitted to a resource manager, which will
|
||||
schedule the resources. Depending on the resource backend, this may instruct
|
||||
Ray to scale up (autoscaling).
|
||||
|
||||
Resource requests are compatible with the most fine-grained low-level resource
|
||||
backend, which are Ray placement groups.
|
||||
|
||||
Args:
|
||||
bundles: A list of bundles which represent the resources requirements.
|
||||
E.g. ``[{"CPU": 1, "GPU": 1}]``.
|
||||
strategy: The scheduling strategy to acquire the bundles.
|
||||
|
||||
- "PACK": Packs Bundles into as few nodes as possible.
|
||||
- "SPREAD": Places Bundles across distinct nodes as even as possible.
|
||||
- "STRICT_PACK": Packs Bundles into one node. The group is
|
||||
not allowed to span multiple nodes.
|
||||
- "STRICT_SPREAD": Packs Bundles across distinct nodes.
|
||||
*args: Passed to the call of ``placement_group()``, if applicable.
|
||||
**kwargs: Passed to the call of ``placement_group()``, if applicable.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bundles: List[Dict[str, Union[int, float]]],
|
||||
strategy: str = "PACK",
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if not bundles:
|
||||
raise ValueError("Cannot initialize a ResourceRequest with zero bundles.")
|
||||
|
||||
# Remove empty resource keys
|
||||
self._bundles = [
|
||||
{k: float(v) for k, v in bundle.items() if v != 0} for bundle in bundles
|
||||
]
|
||||
|
||||
# Check if the head bundle is empty (no resources defined or all resources
|
||||
# are 0 (and thus removed in the previous step)
|
||||
if not self._bundles[0]:
|
||||
# This is when the head bundle doesn't need resources.
|
||||
self._head_bundle_is_empty = True
|
||||
self._bundles.pop(0)
|
||||
|
||||
if not self._bundles:
|
||||
raise ValueError(
|
||||
"Cannot initialize a ResourceRequest with an empty head "
|
||||
"and zero worker bundles."
|
||||
)
|
||||
else:
|
||||
self._head_bundle_is_empty = False
|
||||
|
||||
self._strategy = strategy
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
self._hash = None
|
||||
self._bound = None
|
||||
|
||||
self._bind()
|
||||
|
||||
@property
|
||||
def head_bundle_is_empty(self):
|
||||
"""Returns True if head bundle is empty while child bundles
|
||||
need resources.
|
||||
|
||||
This is considered an internal API within Tune.
|
||||
"""
|
||||
return self._head_bundle_is_empty
|
||||
|
||||
@property
|
||||
@DeveloperAPI
|
||||
def head_cpus(self) -> float:
|
||||
"""Returns the number of cpus in the head bundle."""
|
||||
return 0.0 if self._head_bundle_is_empty else self._bundles[0].get("CPU", 0.0)
|
||||
|
||||
@property
|
||||
@DeveloperAPI
|
||||
def bundles(self) -> List[Dict[str, float]]:
|
||||
"""Returns a deep copy of resource bundles"""
|
||||
return deepcopy(self._bundles)
|
||||
|
||||
@property
|
||||
def required_resources(self) -> Dict[str, float]:
|
||||
"""Returns a dict containing the sums of all resources"""
|
||||
return _sum_bundles(self._bundles)
|
||||
|
||||
@property
|
||||
@DeveloperAPI
|
||||
def strategy(self) -> str:
|
||||
"""Returns the placement strategy"""
|
||||
return self._strategy
|
||||
|
||||
def _bind(self):
|
||||
"""Bind the args and kwargs to the `placement_group()` signature.
|
||||
|
||||
We bind the args and kwargs, so we can compare equality of two resource
|
||||
requests. The main reason for this is that the `placement_group()` API
|
||||
can evolve independently from the ResourceRequest API (e.g. adding new
|
||||
arguments). Then, `ResourceRequest(bundles, strategy, arg=arg)` should
|
||||
be the same as `ResourceRequest(bundles, strategy, arg)`.
|
||||
"""
|
||||
sig = signature(placement_group)
|
||||
try:
|
||||
self._bound = sig.bind(
|
||||
self._bundles, self._strategy, *self._args, **self._kwargs
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Invalid definition for resource request. Please check "
|
||||
"that you passed valid arguments to the ResourceRequest "
|
||||
"object."
|
||||
) from exc
|
||||
|
||||
def to_placement_group(self):
|
||||
return placement_group(*self._bound.args, **self._bound.kwargs)
|
||||
|
||||
def __eq__(self, other: "ResourceRequest"):
|
||||
return (
|
||||
isinstance(other, ResourceRequest)
|
||||
and self._bound == other._bound
|
||||
and self.head_bundle_is_empty == other.head_bundle_is_empty
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
if not self._hash:
|
||||
# Cache hash
|
||||
self._hash = hash(
|
||||
json.dumps(
|
||||
{"args": self._bound.args, "kwargs": self._bound.kwargs},
|
||||
sort_keys=True,
|
||||
indent=0,
|
||||
ensure_ascii=True,
|
||||
)
|
||||
)
|
||||
return self._hash
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state.pop("_hash", None)
|
||||
state.pop("_bound", None)
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
self.__dict__.update(state)
|
||||
self._hash = None
|
||||
self._bound = None
|
||||
self._bind()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ResourceRequest (_bound={self._bound}, "
|
||||
f"head_bundle_is_empty={self.head_bundle_is_empty})>"
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class AcquiredResources(abc.ABC):
|
||||
"""Base class for resources that have been acquired.
|
||||
|
||||
Acquired resources can be associated to Ray objects, which can then be
|
||||
scheduled using these resources.
|
||||
|
||||
Internally this can point e.g. to a placement group, a placement
|
||||
group bundle index, or just raw resources.
|
||||
|
||||
The main API is the `annotate_remote_entities` method. This will associate
|
||||
remote Ray objects (tasks and actors) with the acquired resources by setting
|
||||
the Ray remote options to use the acquired resources.
|
||||
"""
|
||||
|
||||
resource_request: ResourceRequest
|
||||
|
||||
def annotate_remote_entities(
|
||||
self, entities: List[RemoteRayEntity]
|
||||
) -> List[Union[RemoteRayEntity]]:
|
||||
"""Return remote ray entities (tasks/actors) to use the acquired resources.
|
||||
|
||||
The first entity will be associated with the first bundle, the second
|
||||
entity will be associated with the second bundle, etc.
|
||||
|
||||
Args:
|
||||
entities: Remote Ray entities to annotate with the acquired resources.
|
||||
|
||||
Returns:
|
||||
The list of annotated remote Ray entities.
|
||||
"""
|
||||
bundles = self.resource_request.bundles
|
||||
|
||||
# Also count the empty head bundle as a bundle
|
||||
num_bundles = len(bundles) + int(self.resource_request.head_bundle_is_empty)
|
||||
|
||||
if len(entities) > num_bundles:
|
||||
raise RuntimeError(
|
||||
f"The number of callables to annotate ({len(entities)}) cannot "
|
||||
f"exceed the number of available bundles ({num_bundles})."
|
||||
)
|
||||
|
||||
annotated = []
|
||||
|
||||
if self.resource_request.head_bundle_is_empty:
|
||||
# The empty head bundle is place on the first bundle index with empty
|
||||
# resources.
|
||||
annotated.append(
|
||||
self._annotate_remote_entity(entities[0], {}, bundle_index=0)
|
||||
)
|
||||
|
||||
# Shift the remaining entities
|
||||
entities = entities[1:]
|
||||
|
||||
for i, (entity, bundle) in enumerate(zip(entities, bundles)):
|
||||
annotated.append(
|
||||
self._annotate_remote_entity(entity, bundle, bundle_index=i)
|
||||
)
|
||||
|
||||
return annotated
|
||||
|
||||
def _annotate_remote_entity(
|
||||
self, entity: RemoteRayEntity, bundle: Dict[str, float], bundle_index: int
|
||||
) -> RemoteRayEntity:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,155 @@
|
||||
import abc
|
||||
from typing import List, Optional
|
||||
|
||||
import ray
|
||||
from ray.air.execution.resources.request import AcquiredResources, ResourceRequest
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ResourceManager(abc.ABC):
|
||||
"""Resource manager interface.
|
||||
|
||||
A resource manager can be used to request resources from a Ray cluster and
|
||||
allocate them to remote Ray tasks or actors.
|
||||
|
||||
Resources have to be requested before they can be acquired.
|
||||
|
||||
Resources managed by the resource manager can be in three states:
|
||||
|
||||
1. "Requested": The resources have been requested but are not yet available to
|
||||
schedule remote Ray objects. The resource request may trigger autoscaling,
|
||||
and can be cancelled if no longer needed.
|
||||
2. "Ready": The requested resources are now available to schedule remote Ray
|
||||
objects. They can be acquired and subsequently used remote Ray objects.
|
||||
The resource request can still be cancelled if no longer needed.
|
||||
3. "Acquired": The resources have been acquired by a caller to use for scheduling
|
||||
remote Ray objects. Note that it is the responsibility of the caller to
|
||||
schedule the Ray objects with these resources.
|
||||
The associated resource request has been completed and can no longer be
|
||||
cancelled. The acquired resources can be freed by the resource manager when
|
||||
they are no longer used.
|
||||
|
||||
The flow is as follows:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Create resource manager
|
||||
resource_manager = ResourceManager()
|
||||
|
||||
# Create resource request
|
||||
resource_request = ResourceRequest([{"CPU": 4}])
|
||||
|
||||
# Pass to resource manager
|
||||
resource_manager.request_resources(resource_request)
|
||||
|
||||
# Wait until ready
|
||||
while not resource_manager.has_resources_ready(resource_request):
|
||||
time.sleep(1)
|
||||
|
||||
# Once ready, acquire resources
|
||||
acquired_resource = resource_manager.acquire_resources(resource_request)
|
||||
|
||||
# Bind to remote task or actor
|
||||
annotated_remote_fn = acquired_resource.annotate_remote_entities(
|
||||
[remote_fn])
|
||||
|
||||
# Run remote function. This will use the acquired resources
|
||||
ray.get(annotated_remote_fn.remote())
|
||||
|
||||
# After using the resources, free
|
||||
resource_manager.free_resources(annotated_resources)
|
||||
|
||||
"""
|
||||
|
||||
def request_resources(self, resource_request: ResourceRequest):
|
||||
"""Request resources.
|
||||
|
||||
Depending on the backend, resources can trigger autoscaling. Requested
|
||||
resources can be ready or not ready. Once they are "ready", they can
|
||||
be acquired and used by remote Ray objects.
|
||||
|
||||
Resource requests can be cancelled anytime using ``cancel_resource_request()``.
|
||||
Once acquired, the resource request is removed. Acquired resources can be
|
||||
freed with ``free_resources()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def cancel_resource_request(self, resource_request: ResourceRequest):
|
||||
"""Cancel resource request.
|
||||
|
||||
Resource requests can be cancelled anytime before a resource is acquired.
|
||||
Acquiring a resource will remove the associated resource request.
|
||||
Acquired resources can be freed with ``free_resources()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def has_resources_ready(self, resource_request: ResourceRequest) -> bool:
|
||||
"""Returns True if resources for the given request are ready to be acquired."""
|
||||
raise NotImplementedError
|
||||
|
||||
def acquire_resources(
|
||||
self, resource_request: ResourceRequest
|
||||
) -> Optional[AcquiredResources]:
|
||||
"""Acquire resources. Returns None if resources are not ready to be acquired.
|
||||
|
||||
Acquiring resources will remove the associated resource request.
|
||||
Acquired resources can be returned with ``free_resources()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def free_resources(self, acquired_resource: AcquiredResources):
|
||||
"""Free acquired resources from usage and return them to the resource manager.
|
||||
|
||||
Freeing resources will return the resources to the manager, but there are
|
||||
no guarantees about the tasks and actors scheduled on the resources. The caller
|
||||
should make sure that any references to tasks or actors scheduled on the
|
||||
resources have been removed before calling ``free_resources()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_resource_futures(self) -> List[ray.ObjectRef]:
|
||||
"""Return futures for resources to await.
|
||||
|
||||
Depending on the backend, we use resource futures to determine availability
|
||||
of resources (e.g. placement groups) or resolution of requests.
|
||||
In this case, the futures can be awaited externally by the caller.
|
||||
|
||||
When a resource future resolved, the caller may call ``update_state()``
|
||||
to force the resource manager to update its internal state immediately.
|
||||
"""
|
||||
return []
|
||||
|
||||
def update_state(self):
|
||||
"""Update internal state of the resource manager.
|
||||
|
||||
The resource manager may have internal state that needs periodic updating.
|
||||
For instance, depending on the backend, resource futures can be awaited
|
||||
externally (with ``get_resource_futures()``).
|
||||
|
||||
If such a future resolved, the caller can instruct the resource
|
||||
manager to update its internal state immediately.
|
||||
"""
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
"""Reset internal state and clear all resources.
|
||||
|
||||
Calling this method will reset the resource manager to its initialization state.
|
||||
All resources will be removed.
|
||||
|
||||
Clearing the state will remove tracked resources from the manager, but there are
|
||||
no guarantees about the tasks and actors scheduled on the resources. The caller
|
||||
should make sure that any references to tasks or actors scheduled on the
|
||||
resources have been removed before calling ``clear()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __reduce__(self):
|
||||
"""We disallow serialization.
|
||||
|
||||
Shared resource managers should live on an actor.
|
||||
"""
|
||||
raise ValueError(
|
||||
f"Resource managers cannot be serialized. Resource manager: {str(self)}"
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import pyarrow.fs
|
||||
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.logger import LoggerCallback
|
||||
from ray.tune.utils import flatten_dict
|
||||
|
||||
|
||||
def _import_comet():
|
||||
"""Try importing comet_ml.
|
||||
|
||||
Used to check if comet_ml is installed and, otherwise, pass an informative
|
||||
error message.
|
||||
"""
|
||||
if "COMET_DISABLE_AUTO_LOGGING" not in os.environ:
|
||||
os.environ["COMET_DISABLE_AUTO_LOGGING"] = "1"
|
||||
|
||||
try:
|
||||
import comet_ml # noqa: F401
|
||||
except ImportError:
|
||||
raise RuntimeError("pip install 'comet-ml' to use CometLoggerCallback")
|
||||
|
||||
return comet_ml
|
||||
|
||||
|
||||
class CometLoggerCallback(LoggerCallback):
|
||||
"""CometLoggerCallback for logging Tune results to Comet.
|
||||
|
||||
Comet (https://comet.ml/site/) is a tool to manage and optimize the
|
||||
entire ML lifecycle, from experiment tracking, model optimization
|
||||
and dataset versioning to model production monitoring.
|
||||
|
||||
This Ray Tune ``LoggerCallback`` sends metrics and parameters to
|
||||
Comet for tracking.
|
||||
|
||||
In order to use the CometLoggerCallback you must first install Comet
|
||||
via ``pip install comet_ml``
|
||||
|
||||
Then set the following environment variables
|
||||
``export COMET_API_KEY=<Your API Key>``
|
||||
|
||||
Alternatively, you can also pass in your API Key as an argument to the
|
||||
CometLoggerCallback constructor.
|
||||
|
||||
``CometLoggerCallback(api_key=<Your API Key>)``
|
||||
|
||||
Args:
|
||||
online: Whether to make use of an Online or
|
||||
Offline Experiment. Defaults to True.
|
||||
tags: Tags to add to the logged Experiment.
|
||||
Defaults to None.
|
||||
save_checkpoints: If ``True``, model checkpoints will be saved to
|
||||
Comet ML as artifacts. Defaults to ``False``.
|
||||
**experiment_kwargs: Other keyword arguments will be passed to the
|
||||
constructor for comet_ml.Experiment (or OfflineExperiment if
|
||||
online=False).
|
||||
|
||||
Please consult the Comet ML documentation for more information on the
|
||||
Experiment and OfflineExperiment classes: https://comet.ml/site/
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.comet import CometLoggerCallback
|
||||
tune.run(
|
||||
train,
|
||||
config=config
|
||||
callbacks=[CometLoggerCallback(
|
||||
True,
|
||||
['tag1', 'tag2'],
|
||||
workspace='my_workspace',
|
||||
project_name='my_project_name'
|
||||
)]
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
# Do not enable these auto log options unless overridden
|
||||
_exclude_autolog = [
|
||||
"auto_output_logging",
|
||||
"log_git_metadata",
|
||||
"log_git_patch",
|
||||
"log_env_cpu",
|
||||
"log_env_gpu",
|
||||
]
|
||||
|
||||
# Do not log these metrics.
|
||||
_exclude_results = ["done", "should_checkpoint"]
|
||||
|
||||
# These values should be logged as system info instead of metrics.
|
||||
_system_results = ["node_ip", "hostname", "pid", "date"]
|
||||
|
||||
# These values should be logged as "Other" instead of as metrics.
|
||||
_other_results = ["trial_id", "experiment_id", "experiment_tag"]
|
||||
|
||||
_episode_results = ["hist_stats/episode_reward", "hist_stats/episode_lengths"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
online: bool = True,
|
||||
tags: List[str] = None,
|
||||
save_checkpoints: bool = False,
|
||||
**experiment_kwargs,
|
||||
):
|
||||
_import_comet()
|
||||
self.online = online
|
||||
self.tags = tags
|
||||
self.save_checkpoints = save_checkpoints
|
||||
self.experiment_kwargs = experiment_kwargs
|
||||
|
||||
# Disable the specific autologging features that cause throttling.
|
||||
self._configure_experiment_defaults()
|
||||
|
||||
# Mapping from trial to experiment object.
|
||||
self._trial_experiments = {}
|
||||
|
||||
self._to_exclude = self._exclude_results.copy()
|
||||
self._to_system = self._system_results.copy()
|
||||
self._to_other = self._other_results.copy()
|
||||
self._to_episodes = self._episode_results.copy()
|
||||
|
||||
def _configure_experiment_defaults(self):
|
||||
"""Disable the specific autologging features that cause throttling."""
|
||||
for option in self._exclude_autolog:
|
||||
if not self.experiment_kwargs.get(option):
|
||||
self.experiment_kwargs[option] = False
|
||||
|
||||
def _check_key_name(self, key: str, item: str) -> bool:
|
||||
"""
|
||||
Check if key argument is equal to item argument or starts with item and
|
||||
a forward slash. Used for parsing trial result dictionary into ignored
|
||||
keys, system metrics, episode logs, etc.
|
||||
"""
|
||||
return key.startswith(item + "/") or key == item
|
||||
|
||||
def log_trial_start(self, trial: "Trial"):
|
||||
"""
|
||||
Initialize an Experiment (or OfflineExperiment if self.online=False)
|
||||
and start logging to Comet.
|
||||
|
||||
Args:
|
||||
trial: Trial object.
|
||||
|
||||
"""
|
||||
_import_comet() # is this necessary?
|
||||
from comet_ml import Experiment, OfflineExperiment
|
||||
from comet_ml.config import set_global_experiment
|
||||
|
||||
if trial not in self._trial_experiments:
|
||||
experiment_cls = Experiment if self.online else OfflineExperiment
|
||||
experiment = experiment_cls(**self.experiment_kwargs)
|
||||
self._trial_experiments[trial] = experiment
|
||||
# Set global experiment to None to allow for multiple experiments.
|
||||
set_global_experiment(None)
|
||||
else:
|
||||
experiment = self._trial_experiments[trial]
|
||||
|
||||
experiment.set_name(str(trial))
|
||||
experiment.add_tags(self.tags)
|
||||
experiment.log_other("Created from", "Ray")
|
||||
|
||||
config = trial.config.copy()
|
||||
config.pop("callbacks", None)
|
||||
experiment.log_parameters(config)
|
||||
|
||||
def log_trial_result(self, iteration: int, trial: "Trial", result: Dict):
|
||||
"""
|
||||
Log the current result of a Trial upon each iteration.
|
||||
"""
|
||||
if trial not in self._trial_experiments:
|
||||
self.log_trial_start(trial)
|
||||
experiment = self._trial_experiments[trial]
|
||||
step = result["training_iteration"]
|
||||
|
||||
config_update = result.pop("config", {}).copy()
|
||||
config_update.pop("callbacks", None) # Remove callbacks
|
||||
for k, v in config_update.items():
|
||||
if isinstance(v, dict):
|
||||
experiment.log_parameters(flatten_dict({k: v}, "/"), step=step)
|
||||
|
||||
else:
|
||||
experiment.log_parameter(k, v, step=step)
|
||||
|
||||
other_logs = {}
|
||||
metric_logs = {}
|
||||
system_logs = {}
|
||||
episode_logs = {}
|
||||
|
||||
flat_result = flatten_dict(result, delimiter="/")
|
||||
for k, v in flat_result.items():
|
||||
if any(self._check_key_name(k, item) for item in self._to_exclude):
|
||||
continue
|
||||
|
||||
if any(self._check_key_name(k, item) for item in self._to_other):
|
||||
other_logs[k] = v
|
||||
|
||||
elif any(self._check_key_name(k, item) for item in self._to_system):
|
||||
system_logs[k] = v
|
||||
|
||||
elif any(self._check_key_name(k, item) for item in self._to_episodes):
|
||||
episode_logs[k] = v
|
||||
|
||||
else:
|
||||
metric_logs[k] = v
|
||||
|
||||
experiment.log_others(other_logs)
|
||||
experiment.log_metrics(metric_logs, step=step)
|
||||
|
||||
for k, v in system_logs.items():
|
||||
experiment.log_system_info(k, v)
|
||||
|
||||
for k, v in episode_logs.items():
|
||||
experiment.log_curve(k, x=range(len(v)), y=v, step=step)
|
||||
|
||||
def log_trial_save(self, trial: "Trial"):
|
||||
comet_ml = _import_comet()
|
||||
|
||||
if self.save_checkpoints and trial.checkpoint:
|
||||
experiment = self._trial_experiments[trial]
|
||||
|
||||
artifact = comet_ml.Artifact(
|
||||
name=f"checkpoint_{(str(trial))}", artifact_type="model"
|
||||
)
|
||||
|
||||
checkpoint_root = None
|
||||
|
||||
if isinstance(trial.checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
checkpoint_root = trial.checkpoint.path
|
||||
# Todo: For other filesystems, we may want to use
|
||||
# artifact.add_remote() instead. However, this requires a full
|
||||
# URI. We can add this once we have a way to retrieve it.
|
||||
|
||||
# Walk through checkpoint directory and add all files to artifact
|
||||
if checkpoint_root:
|
||||
for root, dirs, files in os.walk(checkpoint_root):
|
||||
rel_root = os.path.relpath(root, checkpoint_root)
|
||||
for file in files:
|
||||
local_file = Path(checkpoint_root, rel_root, file).as_posix()
|
||||
logical_path = Path(rel_root, file).as_posix()
|
||||
|
||||
# Strip leading `./`
|
||||
if logical_path.startswith("./"):
|
||||
logical_path = logical_path[2:]
|
||||
|
||||
artifact.add(local_file, logical_path=logical_path)
|
||||
|
||||
experiment.log_artifact(artifact)
|
||||
|
||||
def log_trial_end(self, trial: "Trial", failed: bool = False):
|
||||
self._trial_experiments[trial].end()
|
||||
del self._trial_experiments[trial]
|
||||
|
||||
def __del__(self):
|
||||
for trial, experiment in self._trial_experiments.items():
|
||||
experiment.end()
|
||||
self._trial_experiments = {}
|
||||
@@ -0,0 +1,185 @@
|
||||
import shutil
|
||||
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 Air'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")
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
class ReportCheckpointCallback(_Callback):
|
||||
"""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:
|
||||
.. code-block:: 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:
|
||||
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".
|
||||
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".
|
||||
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.
|
||||
"""
|
||||
|
||||
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 _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)
|
||||
ray.train.report(metrics, checkpoint=checkpoint)
|
||||
# Clean up temporary checkpoint
|
||||
shutil.rmtree(checkpoint.path, ignore_errors=True)
|
||||
else:
|
||||
ray.train.report(metrics, checkpoint=None)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,343 @@
|
||||
import logging
|
||||
from types import ModuleType
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import ray
|
||||
from ray.air._internal import usage as air_usage
|
||||
from ray.air._internal.mlflow import _MLflowLoggerUtil
|
||||
from ray.air.constants import TRAINING_ITERATION
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.logger import LoggerCallback
|
||||
from ray.tune.result import TIMESTEPS_TOTAL
|
||||
from ray.tune.trainable.trainable_fn_utils import _in_tune_session
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
try:
|
||||
import mlflow
|
||||
except ImportError:
|
||||
mlflow = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _NoopModule:
|
||||
def __getattr__(self, item):
|
||||
return _NoopModule()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
def setup_mlflow(
|
||||
config: Optional[Dict] = None,
|
||||
tracking_uri: Optional[str] = None,
|
||||
registry_uri: Optional[str] = None,
|
||||
experiment_id: Optional[str] = None,
|
||||
experiment_name: Optional[str] = None,
|
||||
tracking_token: Optional[str] = None,
|
||||
artifact_location: Optional[str] = None,
|
||||
run_name: Optional[str] = None,
|
||||
create_experiment_if_not_exists: bool = False,
|
||||
tags: Optional[Dict] = None,
|
||||
rank_zero_only: bool = True,
|
||||
) -> Union[ModuleType, _NoopModule]:
|
||||
"""Set up a MLflow session.
|
||||
|
||||
This function can be used to initialize an MLflow session in a
|
||||
(distributed) training or tuning run. The session will be created on the trainable.
|
||||
|
||||
By default, the MLflow experiment ID is the Ray trial ID and the
|
||||
MLlflow experiment name is the Ray trial name. These settings can be overwritten by
|
||||
passing the respective keyword arguments.
|
||||
|
||||
The ``config`` dict is automatically logged as the run parameters (excluding the
|
||||
mlflow settings).
|
||||
|
||||
In distributed training with Ray Train, only the zero-rank worker will initialize
|
||||
mlflow. All other workers will return a noop client, so that logging is not
|
||||
duplicated in a distributed run. This can be disabled by passing
|
||||
``rank_zero_only=False``, which will then initialize mlflow in every training
|
||||
worker. Note: for Ray Tune, there's no concept of worker ranks, so the `rank_zero_only` is ignored.
|
||||
|
||||
This function will return the ``mlflow`` module or a noop module for
|
||||
non-rank zero workers ``if rank_zero_only=True``. By using
|
||||
``mlflow = setup_mlflow(config)`` you can ensure that only the rank zero worker
|
||||
calls the mlflow API.
|
||||
|
||||
Args:
|
||||
config: Configuration dict to be logged to mlflow as parameters.
|
||||
tracking_uri: The tracking URI for MLflow tracking. If using
|
||||
Tune in a multi-node setting, make sure to use a remote server for
|
||||
tracking.
|
||||
registry_uri: The registry URI for the MLflow model registry.
|
||||
experiment_id: The id of an already created MLflow experiment.
|
||||
All logs from all trials in ``tune.Tuner()`` will be reported to this
|
||||
experiment. If this is not provided or the experiment with this
|
||||
id does not exist, you must provide an``experiment_name``. This
|
||||
parameter takes precedence over ``experiment_name``.
|
||||
experiment_name: The name of an already existing MLflow
|
||||
experiment. All logs from all trials in ``tune.Tuner()`` will be
|
||||
reported to this experiment. If this is not provided, you must
|
||||
provide a valid ``experiment_id``.
|
||||
tracking_token: A token to use for HTTP authentication when
|
||||
logging to a remote tracking server. This is useful when you
|
||||
want to log to a Databricks server, for example. This value will
|
||||
be used to set the MLFLOW_TRACKING_TOKEN environment variable on
|
||||
all the remote training processes.
|
||||
artifact_location: The location to store run artifacts.
|
||||
If not provided, MLFlow picks an appropriate default.
|
||||
Ignored if experiment already exists.
|
||||
run_name: Name of the new MLflow run that will be created.
|
||||
If not set, will default to the ``experiment_name``.
|
||||
create_experiment_if_not_exists: Whether to create an
|
||||
experiment with the provided name if it does not already
|
||||
exist. Defaults to False.
|
||||
tags: Tags to set for the new run.
|
||||
rank_zero_only: If True, will return an initialized session only for the
|
||||
rank 0 worker in distributed training. If False, will initialize a
|
||||
session for all workers. Defaults to True.
|
||||
|
||||
Example:
|
||||
|
||||
Per default, you can just call ``setup_mlflow`` and continue to use
|
||||
MLflow like you would normally do:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.mlflow import setup_mlflow
|
||||
|
||||
def training_loop(config):
|
||||
mlflow = setup_mlflow(config)
|
||||
# ...
|
||||
mlflow.log_metric(key="loss", val=0.123, step=0)
|
||||
|
||||
In distributed data parallel training, you can utilize the return value of
|
||||
``setup_mlflow``. This will make sure it is only invoked on the first worker
|
||||
in distributed training runs.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.mlflow import setup_mlflow
|
||||
|
||||
def training_loop(config):
|
||||
mlflow = setup_mlflow(config)
|
||||
# ...
|
||||
mlflow.log_metric(key="loss", val=0.123, step=0)
|
||||
|
||||
|
||||
You can also use MlFlow's autologging feature if using a training
|
||||
framework like Pytorch Lightning, XGBoost, etc. More information can be
|
||||
found here
|
||||
(https://mlflow.org/docs/latest/tracking.html#automatic-logging).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.mlflow import setup_mlflow
|
||||
|
||||
def train_fn(config):
|
||||
mlflow = setup_mlflow(config)
|
||||
mlflow.autolog()
|
||||
xgboost_results = xgb.train(config, ...)
|
||||
|
||||
Returns:
|
||||
The ``mlflow`` module, or a noop module for non-rank-zero workers when
|
||||
``rank_zero_only`` is True.
|
||||
"""
|
||||
if not mlflow:
|
||||
raise RuntimeError(
|
||||
"mlflow was not found - please install with `pip install mlflow`"
|
||||
)
|
||||
|
||||
default_trial_id = None
|
||||
default_trial_name = None
|
||||
|
||||
try:
|
||||
if _in_tune_session():
|
||||
context: ray.tune.TuneContext = ray.tune.get_context()
|
||||
default_trial_id = context.get_trial_id()
|
||||
default_trial_name = context.get_trial_name()
|
||||
else:
|
||||
context: ray.train.TrainContext = ray.train.get_context()
|
||||
if rank_zero_only and context.get_world_rank() != 0:
|
||||
return _NoopModule()
|
||||
except RuntimeError:
|
||||
default_trial_id = None
|
||||
default_trial_name = None
|
||||
|
||||
_config = config.copy() if config else {}
|
||||
|
||||
experiment_id = experiment_id or default_trial_id
|
||||
experiment_name = experiment_name or default_trial_name
|
||||
|
||||
# Setup mlflow
|
||||
mlflow_util = _MLflowLoggerUtil()
|
||||
mlflow_util.setup_mlflow(
|
||||
tracking_uri=tracking_uri,
|
||||
registry_uri=registry_uri,
|
||||
experiment_id=experiment_id,
|
||||
experiment_name=experiment_name,
|
||||
tracking_token=tracking_token,
|
||||
artifact_location=artifact_location,
|
||||
create_experiment_if_not_exists=create_experiment_if_not_exists,
|
||||
)
|
||||
|
||||
mlflow_util.start_run(
|
||||
run_name=run_name or experiment_name,
|
||||
tags=tags,
|
||||
set_active=True,
|
||||
)
|
||||
mlflow_util.log_params(_config)
|
||||
|
||||
# Record `setup_mlflow` usage when everything has setup successfully.
|
||||
air_usage.tag_setup_mlflow()
|
||||
|
||||
return mlflow_util._mlflow
|
||||
|
||||
|
||||
class MLflowLoggerCallback(LoggerCallback):
|
||||
"""MLflow Logger to automatically log Tune results and config to MLflow.
|
||||
|
||||
MLflow (https://mlflow.org) Tracking is an open source library for
|
||||
recording and querying experiments. This Ray Tune ``LoggerCallback``
|
||||
sends information (config parameters, training results & metrics,
|
||||
and artifacts) to MLflow for automatic experiment tracking.
|
||||
|
||||
Keep in mind that the callback will open an MLflow session on the driver and
|
||||
not on the trainable. Therefore, it is not possible to call MLflow functions
|
||||
like ``mlflow.log_figure()`` inside the trainable as there is no MLflow session
|
||||
on the trainable. For more fine grained control, use
|
||||
:func:`ray.air.integrations.mlflow.setup_mlflow`.
|
||||
|
||||
Args:
|
||||
tracking_uri: The tracking URI for where to manage experiments
|
||||
and runs. This can either be a local file path or a remote server.
|
||||
This arg gets passed directly to mlflow
|
||||
initialization. When using Tune in a multi-node setting, make sure
|
||||
to set this to a remote server and not a local file path.
|
||||
registry_uri: The registry URI that gets passed directly to
|
||||
mlflow initialization.
|
||||
experiment_name: The experiment name to use for this Tune run.
|
||||
If the experiment with the name already exists with MLflow,
|
||||
it will be reused. If not, a new experiment will be created with
|
||||
that name.
|
||||
tags: An optional dictionary of string keys and values to set
|
||||
as tags on the run
|
||||
tracking_token: Tracking token used to authenticate with MLflow.
|
||||
save_artifact: If set to True, automatically save the entire
|
||||
contents of the Tune local_dir as an artifact to the
|
||||
corresponding run in MlFlow.
|
||||
log_params_on_trial_end: If set to True, log parameters to MLflow
|
||||
at the end of the trial instead of at the beginning
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.mlflow import MLflowLoggerCallback
|
||||
|
||||
tags = { "user_name" : "John",
|
||||
"git_commit_hash" : "abc123"}
|
||||
|
||||
tune.run(
|
||||
train_fn,
|
||||
config={
|
||||
# define search space here
|
||||
"parameter_1": tune.choice([1, 2, 3]),
|
||||
"parameter_2": tune.choice([4, 5, 6]),
|
||||
},
|
||||
callbacks=[MLflowLoggerCallback(
|
||||
experiment_name="experiment1",
|
||||
tags=tags,
|
||||
save_artifact=True,
|
||||
log_params_on_trial_end=True)])
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tracking_uri: Optional[str] = None,
|
||||
*,
|
||||
registry_uri: Optional[str] = None,
|
||||
experiment_name: Optional[str] = None,
|
||||
tags: Optional[Dict] = None,
|
||||
tracking_token: Optional[str] = None,
|
||||
save_artifact: bool = False,
|
||||
log_params_on_trial_end: bool = False,
|
||||
):
|
||||
|
||||
self.tracking_uri = tracking_uri
|
||||
self.registry_uri = registry_uri
|
||||
self.experiment_name = experiment_name
|
||||
self.tags = tags
|
||||
self.tracking_token = tracking_token
|
||||
self.should_save_artifact = save_artifact
|
||||
self.log_params_on_trial_end = log_params_on_trial_end
|
||||
|
||||
self.mlflow_util = _MLflowLoggerUtil()
|
||||
|
||||
if ray.util.client.ray.is_connected():
|
||||
logger.warning(
|
||||
"When using MLflowLoggerCallback with Ray Client, "
|
||||
"it is recommended to use a remote tracking "
|
||||
"server. If you are using a MLflow tracking server "
|
||||
"backed by the local filesystem, then it must be "
|
||||
"setup on the server side and not on the client "
|
||||
"side."
|
||||
)
|
||||
|
||||
def setup(self, *args, **kwargs):
|
||||
# Setup the mlflow logging util.
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri,
|
||||
registry_uri=self.registry_uri,
|
||||
experiment_name=self.experiment_name,
|
||||
tracking_token=self.tracking_token,
|
||||
)
|
||||
|
||||
if self.tags is None:
|
||||
# Create empty dictionary for tags if not given explicitly
|
||||
self.tags = {}
|
||||
|
||||
self._trial_runs = {}
|
||||
|
||||
def log_trial_start(self, trial: "Trial"):
|
||||
# Create run if not already exists.
|
||||
if trial not in self._trial_runs:
|
||||
|
||||
# Set trial name in tags
|
||||
tags = self.tags.copy()
|
||||
tags["trial_name"] = str(trial)
|
||||
|
||||
run = self.mlflow_util.start_run(tags=tags, run_name=str(trial))
|
||||
self._trial_runs[trial] = run.info.run_id
|
||||
|
||||
run_id = self._trial_runs[trial]
|
||||
|
||||
# Log the config parameters.
|
||||
config = trial.config
|
||||
if not self.log_params_on_trial_end:
|
||||
self.mlflow_util.log_params(run_id=run_id, params_to_log=config)
|
||||
|
||||
def log_trial_result(self, iteration: int, trial: "Trial", result: Dict):
|
||||
step = result.get(TIMESTEPS_TOTAL) or result[TRAINING_ITERATION]
|
||||
run_id = self._trial_runs[trial]
|
||||
self.mlflow_util.log_metrics(run_id=run_id, metrics_to_log=result, step=step)
|
||||
|
||||
def log_trial_end(self, trial: "Trial", failed: bool = False):
|
||||
run_id = self._trial_runs[trial]
|
||||
|
||||
# Log the artifact if set_artifact is set to True.
|
||||
if self.should_save_artifact:
|
||||
self.mlflow_util.save_artifacts(run_id=run_id, dir=trial.local_path)
|
||||
|
||||
# Stop the run once trial finishes.
|
||||
status = "FINISHED" if not failed else "FAILED"
|
||||
|
||||
# Log the config parameters.
|
||||
config = trial.config
|
||||
if self.log_params_on_trial_end:
|
||||
self.mlflow_util.log_params(run_id=run_id, params_to_log=config)
|
||||
|
||||
self.mlflow_util.end_run(run_id=run_id, status=status)
|
||||
@@ -0,0 +1,810 @@
|
||||
import enum
|
||||
import os
|
||||
import pickle
|
||||
import urllib
|
||||
import warnings
|
||||
from numbers import Number
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pyarrow.fs
|
||||
|
||||
import ray
|
||||
from ray import logger
|
||||
from ray._common.utils import load_class
|
||||
from ray.air._internal import usage as air_usage
|
||||
from ray.air.constants import TRAINING_ITERATION
|
||||
from ray.air.util.node import _force_on_current_node
|
||||
from ray.train._internal.session import get_session
|
||||
from ray.train._internal.syncer import DEFAULT_SYNC_TIMEOUT
|
||||
from ray.tune.experiment import Trial
|
||||
from ray.tune.logger import LoggerCallback
|
||||
from ray.tune.utils import flatten_dict
|
||||
from ray.util import PublicAPI
|
||||
from ray.util.queue import Queue
|
||||
|
||||
try:
|
||||
import wandb
|
||||
from wandb.sdk.data_types.base_types.wb_value import WBValue
|
||||
from wandb.sdk.data_types.image import Image
|
||||
from wandb.sdk.data_types.video import Video
|
||||
from wandb.sdk.lib.disabled import RunDisabled
|
||||
from wandb.util import json_dumps_safer
|
||||
from wandb.wandb_run import Run
|
||||
except ImportError:
|
||||
wandb = json_dumps_safer = Run = RunDisabled = WBValue = None
|
||||
|
||||
|
||||
WANDB_ENV_VAR = "WANDB_API_KEY"
|
||||
WANDB_PROJECT_ENV_VAR = "WANDB_PROJECT_NAME"
|
||||
WANDB_GROUP_ENV_VAR = "WANDB_GROUP_NAME"
|
||||
WANDB_MODE_ENV_VAR = "WANDB_MODE"
|
||||
# Hook that is invoked before wandb.init in the setup method of WandbLoggerCallback
|
||||
# to populate the API key if it isn't already set when initializing the callback.
|
||||
# It doesn't take in any arguments and returns the W&B API key.
|
||||
# Example: "your.module.wandb_setup_api_key_hook".
|
||||
WANDB_SETUP_API_KEY_HOOK = "WANDB_SETUP_API_KEY_HOOK"
|
||||
# Hook that is invoked before wandb.init in the setup method of WandbLoggerCallback
|
||||
# to populate environment variables to specify the location
|
||||
# (project and group) of the W&B run.
|
||||
# It doesn't take in any arguments and doesn't return anything, but it does populate
|
||||
# WANDB_PROJECT_NAME and WANDB_GROUP_NAME.
|
||||
# Example: "your.module.wandb_populate_run_location_hook".
|
||||
WANDB_POPULATE_RUN_LOCATION_HOOK = "WANDB_POPULATE_RUN_LOCATION_HOOK"
|
||||
# Hook that is invoked after running wandb.init in WandbLoggerCallback
|
||||
# to process information about the W&B run.
|
||||
# It takes in a W&B run object and doesn't return anything.
|
||||
# Example: "your.module.wandb_process_run_info_hook".
|
||||
WANDB_PROCESS_RUN_INFO_HOOK = "WANDB_PROCESS_RUN_INFO_HOOK"
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
def setup_wandb(
|
||||
config: Optional[Dict] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_key_file: Optional[str] = None,
|
||||
rank_zero_only: bool = True,
|
||||
**kwargs,
|
||||
) -> Union[Run, RunDisabled]:
|
||||
"""Set up a Weights & Biases session.
|
||||
|
||||
This function can be used to initialize a Weights & Biases session in a
|
||||
(distributed) training or tuning run.
|
||||
|
||||
By default, the run ID is the trial ID, the run name is the trial name, and
|
||||
the run group is the experiment name. These settings can be overwritten by
|
||||
passing the respective arguments as ``kwargs``, which will be passed to
|
||||
``wandb.init()``.
|
||||
|
||||
In distributed training with Ray Train, only the zero-rank worker will initialize
|
||||
wandb. All other workers will return a disabled run object, so that logging is not
|
||||
duplicated in a distributed run. This can be disabled by passing
|
||||
``rank_zero_only=False``, which will then initialize wandb in every training
|
||||
worker.
|
||||
|
||||
The ``config`` argument will be passed to Weights and Biases and will be logged
|
||||
as the run configuration.
|
||||
|
||||
If no API key or key file are passed, wandb will try to authenticate
|
||||
using locally stored credentials, created for instance by running ``wandb login``.
|
||||
|
||||
Keyword arguments passed to ``setup_wandb()`` will be passed to
|
||||
``wandb.init()`` and take precedence over any potential default settings.
|
||||
|
||||
Args:
|
||||
config: Configuration dict to be logged to Weights and Biases. Can contain
|
||||
arguments for ``wandb.init()`` as well as authentication information.
|
||||
api_key: API key to use for authentication with Weights and Biases.
|
||||
api_key_file: File pointing to API key for with Weights and Biases.
|
||||
rank_zero_only: If True, will return an initialized session only for the
|
||||
rank 0 worker in distributed training. If False, will initialize a
|
||||
session for all workers.
|
||||
**kwargs: Passed to ``wandb.init()``.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.air.integrations.wandb import setup_wandb
|
||||
|
||||
def training_loop(config):
|
||||
wandb = setup_wandb(config)
|
||||
# ...
|
||||
wandb.log({"loss": 0.123})
|
||||
|
||||
Returns:
|
||||
The initialized wandb run, or a disabled run for non-rank-zero workers
|
||||
when ``rank_zero_only`` is True.
|
||||
"""
|
||||
if not wandb:
|
||||
raise RuntimeError(
|
||||
"Wandb was not found - please install with `pip install wandb`"
|
||||
)
|
||||
|
||||
default_trial_id = None
|
||||
default_trial_name = None
|
||||
default_experiment_name = None
|
||||
|
||||
# Do a try-catch here if we are not in a train session
|
||||
session = get_session()
|
||||
|
||||
if rank_zero_only:
|
||||
# Check if we are in a train session and if we are not the rank 0 worker
|
||||
if session and session.world_rank is not None and session.world_rank != 0:
|
||||
return RunDisabled()
|
||||
|
||||
if session:
|
||||
default_trial_id = session.trial_id
|
||||
default_trial_name = session.trial_name
|
||||
default_experiment_name = session.experiment_name
|
||||
|
||||
# Default init kwargs
|
||||
wandb_init_kwargs = {
|
||||
"trial_id": kwargs.get("trial_id") or default_trial_id,
|
||||
"trial_name": kwargs.get("trial_name") or default_trial_name,
|
||||
"group": kwargs.get("group") or default_experiment_name,
|
||||
}
|
||||
# Passed kwargs take precedence over default kwargs
|
||||
wandb_init_kwargs.update(kwargs)
|
||||
|
||||
return _setup_wandb(
|
||||
config=config, api_key=api_key, api_key_file=api_key_file, **wandb_init_kwargs
|
||||
)
|
||||
|
||||
|
||||
def _setup_wandb(
|
||||
trial_id: str,
|
||||
trial_name: str,
|
||||
config: Optional[Dict] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_key_file: Optional[str] = None,
|
||||
_wandb: Optional[ModuleType] = None,
|
||||
**kwargs,
|
||||
) -> Union[Run, RunDisabled]:
|
||||
_config = config.copy() if config else {}
|
||||
|
||||
# If key file is specified, set
|
||||
if api_key_file:
|
||||
api_key_file = os.path.expanduser(api_key_file)
|
||||
|
||||
_set_api_key(api_key_file, api_key)
|
||||
project = _get_wandb_project(kwargs.pop("project", None))
|
||||
group = kwargs.pop("group", os.environ.get(WANDB_GROUP_ENV_VAR))
|
||||
|
||||
# Remove unpickleable items.
|
||||
_config = _clean_log(_config)
|
||||
|
||||
wandb_init_kwargs = dict(
|
||||
id=trial_id,
|
||||
name=trial_name,
|
||||
resume=True,
|
||||
reinit=True,
|
||||
allow_val_change=True,
|
||||
config=_config,
|
||||
project=project,
|
||||
group=group,
|
||||
)
|
||||
|
||||
# Update config (e.g. set any other parameters in the call to wandb.init)
|
||||
wandb_init_kwargs.update(**kwargs)
|
||||
|
||||
# On windows, we can't fork
|
||||
if os.name == "nt":
|
||||
os.environ["WANDB_START_METHOD"] = "thread"
|
||||
else:
|
||||
os.environ["WANDB_START_METHOD"] = "fork"
|
||||
|
||||
_wandb = _wandb or wandb
|
||||
|
||||
run = _wandb.init(**wandb_init_kwargs)
|
||||
_run_wandb_process_run_info_hook(run)
|
||||
|
||||
# Record `setup_wandb` usage when everything has setup successfully.
|
||||
air_usage.tag_setup_wandb()
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _is_allowed_type(obj):
|
||||
"""Return True if type is allowed for logging to wandb"""
|
||||
if isinstance(obj, np.ndarray) and obj.size == 1:
|
||||
return isinstance(obj.item(), Number)
|
||||
if isinstance(obj, Sequence) and len(obj) > 0:
|
||||
return isinstance(obj[0], (Image, Video, WBValue))
|
||||
return isinstance(obj, (Number, WBValue))
|
||||
|
||||
|
||||
def _clean_log(
|
||||
obj: Any,
|
||||
*,
|
||||
video_kwargs: Optional[Dict[str, Any]] = None,
|
||||
image_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
# Fixes https://github.com/ray-project/ray/issues/10631
|
||||
if video_kwargs is None:
|
||||
video_kwargs = {}
|
||||
if image_kwargs is None:
|
||||
image_kwargs = {}
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: _clean_log(v, video_kwargs=video_kwargs, image_kwargs=image_kwargs)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
elif isinstance(obj, (list, set)):
|
||||
return [
|
||||
_clean_log(v, video_kwargs=video_kwargs, image_kwargs=image_kwargs)
|
||||
for v in obj
|
||||
]
|
||||
elif isinstance(obj, tuple):
|
||||
return tuple(
|
||||
_clean_log(v, video_kwargs=video_kwargs, image_kwargs=image_kwargs)
|
||||
for v in obj
|
||||
)
|
||||
elif isinstance(obj, np.ndarray) and obj.ndim == 3:
|
||||
# Must be single image (H, W, C).
|
||||
return Image(obj, **image_kwargs)
|
||||
elif isinstance(obj, np.ndarray) and obj.ndim == 4:
|
||||
# Must be batch of images (N >= 1, H, W, C).
|
||||
return (
|
||||
_clean_log(
|
||||
[Image(v, **image_kwargs) for v in obj],
|
||||
video_kwargs=video_kwargs,
|
||||
image_kwargs=image_kwargs,
|
||||
)
|
||||
if obj.shape[0] > 1
|
||||
else Image(obj[0], **image_kwargs)
|
||||
)
|
||||
elif isinstance(obj, np.ndarray) and obj.ndim == 5:
|
||||
# Must be batch of videos (N >= 1, T, C, W, H).
|
||||
return (
|
||||
_clean_log(
|
||||
[Video(v, **video_kwargs) for v in obj],
|
||||
video_kwargs=video_kwargs,
|
||||
image_kwargs=image_kwargs,
|
||||
)
|
||||
if obj.shape[0] > 1
|
||||
else Video(obj[0], **video_kwargs)
|
||||
)
|
||||
elif _is_allowed_type(obj):
|
||||
return obj
|
||||
|
||||
# Else
|
||||
|
||||
try:
|
||||
# This is what wandb uses internally. If we cannot dump
|
||||
# an object using this method, wandb will raise an exception.
|
||||
json_dumps_safer(obj)
|
||||
|
||||
# This is probably unnecessary, but left here to be extra sure.
|
||||
pickle.dumps(obj)
|
||||
|
||||
return obj
|
||||
except Exception:
|
||||
# give up, similar to _SafeFallBackEncoder
|
||||
fallback = str(obj)
|
||||
|
||||
# Try to convert to int
|
||||
try:
|
||||
fallback = int(fallback)
|
||||
return fallback
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try to convert to float
|
||||
try:
|
||||
fallback = float(fallback)
|
||||
return fallback
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Else, return string
|
||||
return fallback
|
||||
|
||||
|
||||
def _get_wandb_project(project: Optional[str] = None) -> Optional[str]:
|
||||
"""Get W&B project from environment variable or external hook if not passed
|
||||
as and argument."""
|
||||
if (
|
||||
not project
|
||||
and not os.environ.get(WANDB_PROJECT_ENV_VAR)
|
||||
and os.environ.get(WANDB_POPULATE_RUN_LOCATION_HOOK)
|
||||
):
|
||||
# Try to populate WANDB_PROJECT_ENV_VAR and WANDB_GROUP_ENV_VAR
|
||||
# from external hook
|
||||
try:
|
||||
load_class(os.environ[WANDB_POPULATE_RUN_LOCATION_HOOK])()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error executing {WANDB_POPULATE_RUN_LOCATION_HOOK} to "
|
||||
f"populate {WANDB_PROJECT_ENV_VAR} and {WANDB_GROUP_ENV_VAR}: {e}",
|
||||
exc_info=e,
|
||||
)
|
||||
if not project and os.environ.get(WANDB_PROJECT_ENV_VAR):
|
||||
# Try to get project and group from environment variables if not
|
||||
# passed through WandbLoggerCallback.
|
||||
project = os.environ.get(WANDB_PROJECT_ENV_VAR)
|
||||
return project
|
||||
|
||||
|
||||
def _set_api_key(api_key_file: Optional[str] = None, api_key: Optional[str] = None):
|
||||
"""Set WandB API key from `wandb_config`. Will pop the
|
||||
`api_key_file` and `api_key` keys from `wandb_config` parameter.
|
||||
|
||||
The order of fetching the API key is:
|
||||
1) From `api_key` or `api_key_file` arguments
|
||||
2) From WANDB_API_KEY environment variables
|
||||
3) User already logged in to W&B (wandb.api.api_key set)
|
||||
4) From external hook WANDB_SETUP_API_KEY_HOOK
|
||||
"""
|
||||
if os.environ.get(WANDB_MODE_ENV_VAR) in {"offline", "disabled"}:
|
||||
return
|
||||
|
||||
if api_key_file:
|
||||
if api_key:
|
||||
raise ValueError("Both WandB `api_key_file` and `api_key` set.")
|
||||
with open(api_key_file, "rt") as fp:
|
||||
api_key = fp.readline().strip()
|
||||
|
||||
if not api_key and not os.environ.get(WANDB_ENV_VAR):
|
||||
# Check if user is already logged into wandb.
|
||||
try:
|
||||
wandb.ensure_configured()
|
||||
if wandb.api.api_key:
|
||||
logger.info("Already logged into W&B.")
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
# Try to get API key from external hook
|
||||
if WANDB_SETUP_API_KEY_HOOK in os.environ:
|
||||
try:
|
||||
api_key = load_class(os.environ[WANDB_SETUP_API_KEY_HOOK])()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error executing {WANDB_SETUP_API_KEY_HOOK} to setup API key: {e}",
|
||||
exc_info=e,
|
||||
)
|
||||
if api_key:
|
||||
os.environ[WANDB_ENV_VAR] = api_key
|
||||
elif not os.environ.get(WANDB_ENV_VAR):
|
||||
raise ValueError(
|
||||
"No WandB API key found. Either set the {} environment "
|
||||
"variable, pass `api_key` or `api_key_file` to the"
|
||||
"`WandbLoggerCallback` class as arguments, "
|
||||
"or run `wandb login` from the command line".format(WANDB_ENV_VAR)
|
||||
)
|
||||
|
||||
|
||||
def _run_wandb_process_run_info_hook(run: Any) -> None:
|
||||
"""Run external hook to process information about wandb run"""
|
||||
if WANDB_PROCESS_RUN_INFO_HOOK in os.environ:
|
||||
try:
|
||||
load_class(os.environ[WANDB_PROCESS_RUN_INFO_HOOK])(run)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error calling {WANDB_PROCESS_RUN_INFO_HOOK}: {e}", exc_info=e
|
||||
)
|
||||
|
||||
|
||||
class _QueueItem(enum.Enum):
|
||||
END = enum.auto()
|
||||
RESULT = enum.auto()
|
||||
CHECKPOINT = enum.auto()
|
||||
|
||||
|
||||
class _WandbLoggingActor:
|
||||
"""
|
||||
Wandb assumes that each trial's information should be logged from a
|
||||
separate process. We use Ray actors as forking multiprocessing
|
||||
processes is not supported by Ray and spawn processes run into pickling
|
||||
problems.
|
||||
|
||||
We use a queue for the driver to communicate with the logging process.
|
||||
The queue accepts the following items:
|
||||
|
||||
- If it's a dict, it is assumed to be a result and will be logged using
|
||||
``wandb.log()``
|
||||
- If it's a checkpoint object, it will be saved using ``wandb.log_artifact()``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logdir: str,
|
||||
queue: Queue,
|
||||
exclude: List[str],
|
||||
to_config: List[str],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
import wandb
|
||||
|
||||
self._wandb = wandb
|
||||
|
||||
os.chdir(logdir)
|
||||
self.queue = queue
|
||||
self._exclude = set(exclude)
|
||||
self._to_config = set(to_config)
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
self._trial_name = self.kwargs.get("name", "unknown")
|
||||
self._logdir = logdir
|
||||
|
||||
def run(self):
|
||||
# Since we're running in a separate process already, use threads.
|
||||
os.environ["WANDB_START_METHOD"] = "thread"
|
||||
run = self._wandb.init(*self.args, **self.kwargs)
|
||||
run.config.trial_log_path = self._logdir
|
||||
|
||||
_run_wandb_process_run_info_hook(run)
|
||||
|
||||
while True:
|
||||
item_type, item_content = self.queue.get()
|
||||
if item_type == _QueueItem.END:
|
||||
break
|
||||
|
||||
if item_type == _QueueItem.CHECKPOINT:
|
||||
self._handle_checkpoint(item_content)
|
||||
continue
|
||||
|
||||
assert item_type == _QueueItem.RESULT
|
||||
log, config_update = self._handle_result(item_content)
|
||||
try:
|
||||
self._wandb.config.update(config_update, allow_val_change=True)
|
||||
self._wandb.log(log, step=log.get(TRAINING_ITERATION))
|
||||
except urllib.error.HTTPError as e:
|
||||
# Ignore HTTPError. Missing a few data points is not a
|
||||
# big issue, as long as things eventually recover.
|
||||
logger.warning("Failed to log result to w&b: {}".format(str(e)))
|
||||
except FileNotFoundError as e:
|
||||
logger.error(
|
||||
"FileNotFoundError: Did not log result to Weights & Biases. "
|
||||
"Possible cause: relative file path used instead of absolute path. "
|
||||
"Error: %s",
|
||||
e,
|
||||
)
|
||||
self._wandb.finish()
|
||||
|
||||
def _handle_checkpoint(self, checkpoint_path: str):
|
||||
artifact = self._wandb.Artifact(
|
||||
name=f"checkpoint_{self._trial_name}", type="model"
|
||||
)
|
||||
artifact.add_dir(checkpoint_path)
|
||||
self._wandb.log_artifact(artifact)
|
||||
|
||||
def _handle_result(self, result: Dict) -> Tuple[Dict, Dict]:
|
||||
config_update = result.get("config", {}).copy()
|
||||
log = {}
|
||||
flat_result = flatten_dict(result, delimiter="/")
|
||||
|
||||
for k, v in flat_result.items():
|
||||
if any(k.startswith(item + "/") or k == item for item in self._exclude):
|
||||
continue
|
||||
elif any(k.startswith(item + "/") or k == item for item in self._to_config):
|
||||
config_update[k] = v
|
||||
elif not _is_allowed_type(v):
|
||||
continue
|
||||
else:
|
||||
log[k] = v
|
||||
|
||||
config_update.pop("callbacks", None) # Remove callbacks
|
||||
return log, config_update
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
class WandbLoggerCallback(LoggerCallback):
|
||||
"""WandbLoggerCallback
|
||||
|
||||
Weights and biases (https://www.wandb.ai/) is a tool for experiment
|
||||
tracking, model optimization, and dataset versioning. This Ray Tune
|
||||
``LoggerCallback`` sends metrics to Wandb for automatic tracking and
|
||||
visualization.
|
||||
|
||||
Example:
|
||||
|
||||
.. testcode::
|
||||
|
||||
import random
|
||||
|
||||
from ray import tune
|
||||
from ray.air.integrations.wandb import WandbLoggerCallback
|
||||
|
||||
|
||||
def train_func(config):
|
||||
offset = random.random() / 5
|
||||
for epoch in range(2, config["epochs"]):
|
||||
acc = 1 - (2 + config["lr"]) ** -epoch - random.random() / epoch - offset
|
||||
loss = (2 + config["lr"]) ** -epoch + random.random() / epoch + offset
|
||||
train.report({"acc": acc, "loss": loss})
|
||||
|
||||
|
||||
tuner = tune.Tuner(
|
||||
train_func,
|
||||
param_space={
|
||||
"lr": tune.grid_search([0.001, 0.01, 0.1, 1.0]),
|
||||
"epochs": 10,
|
||||
},
|
||||
run_config=tune.RunConfig(
|
||||
callbacks=[WandbLoggerCallback(project="Optimization_Project")]
|
||||
),
|
||||
)
|
||||
results = tuner.fit()
|
||||
|
||||
.. testoutput::
|
||||
:hide:
|
||||
|
||||
...
|
||||
|
||||
Args:
|
||||
project: Name of the Wandb project. Mandatory.
|
||||
group: Name of the Wandb group. Defaults to the trainable
|
||||
name.
|
||||
api_key_file: Path to file containing the Wandb API KEY. This
|
||||
file only needs to be present on the node running the Tune script
|
||||
if using the WandbLogger.
|
||||
api_key: Wandb API Key. Alternative to setting ``api_key_file``.
|
||||
excludes: List of metrics and config that should be excluded from
|
||||
the log.
|
||||
log_config: Boolean indicating if the ``config`` parameter of
|
||||
the ``results`` dict should be logged. This makes sense if
|
||||
parameters will change during training, e.g. with
|
||||
PopulationBasedTraining. Defaults to False.
|
||||
upload_checkpoints: If ``True``, model checkpoints will be uploaded to
|
||||
Wandb as artifacts. Defaults to ``False``.
|
||||
save_checkpoints: Deprecated alias of ``upload_checkpoints``. Defaults to
|
||||
``False``.
|
||||
upload_timeout: Maximum time in seconds to wait for pending uploads to
|
||||
wandb when the experiment ends. Defaults to the Ray Train default
|
||||
sync timeout.
|
||||
video_kwargs: Dictionary of keyword arguments passed to wandb.Video()
|
||||
when logging videos. Videos have to be logged as 5D numpy arrays
|
||||
to be affected by this parameter. For valid keyword arguments, see
|
||||
https://docs.wandb.ai/ref/python/data-types/video/. Defaults to ``None``.
|
||||
image_kwargs: Dictionary of keyword arguments passed to wandb.Image()
|
||||
when logging images. Images have to be logged as 3D or 4D numpy arrays
|
||||
to be affected by this parameter. For valid keyword arguments, see
|
||||
https://docs.wandb.ai/ref/python/data-types/image/. Defaults to ``None``.
|
||||
**kwargs: The keyword arguments will be passed to ``wandb.init()``.
|
||||
|
||||
Wandb's ``group``, ``run_id`` and ``run_name`` are automatically selected
|
||||
by Tune, but can be overwritten by filling out the respective configuration
|
||||
values.
|
||||
|
||||
Please see here for all other valid configuration settings:
|
||||
https://docs.wandb.ai/ref/python/init/
|
||||
""" # noqa: E501
|
||||
|
||||
# Do not log these result keys
|
||||
_exclude_results = ["done", "should_checkpoint"]
|
||||
|
||||
AUTO_CONFIG_KEYS = [
|
||||
"trial_id",
|
||||
"experiment_tag",
|
||||
"node_ip",
|
||||
"experiment_id",
|
||||
"hostname",
|
||||
"pid",
|
||||
"date",
|
||||
]
|
||||
"""Results that are saved with `wandb.config` instead of `wandb.log`."""
|
||||
|
||||
_logger_actor_cls = _WandbLoggingActor
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
group: Optional[str] = None,
|
||||
api_key_file: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
excludes: Optional[List[str]] = None,
|
||||
log_config: bool = False,
|
||||
upload_checkpoints: bool = False,
|
||||
save_checkpoints: bool = False,
|
||||
upload_timeout: int = DEFAULT_SYNC_TIMEOUT,
|
||||
video_kwargs: Optional[dict] = None,
|
||||
image_kwargs: Optional[dict] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not wandb:
|
||||
raise RuntimeError(
|
||||
"Wandb was not found - please install with `pip install wandb`"
|
||||
)
|
||||
|
||||
if save_checkpoints:
|
||||
warnings.warn(
|
||||
"`save_checkpoints` is deprecated. Use `upload_checkpoints` instead.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
upload_checkpoints = save_checkpoints
|
||||
|
||||
self.project = project
|
||||
self.group = group
|
||||
self.api_key_path = api_key_file
|
||||
self.api_key = api_key
|
||||
self.excludes = excludes or []
|
||||
self.log_config = log_config
|
||||
self.upload_checkpoints = upload_checkpoints
|
||||
self._upload_timeout = upload_timeout
|
||||
self.video_kwargs = video_kwargs or {}
|
||||
self.image_kwargs = image_kwargs or {}
|
||||
self.kwargs = kwargs
|
||||
|
||||
self._remote_logger_class = None
|
||||
|
||||
self._trial_logging_actors: Dict[
|
||||
"Trial", ray.actor.ActorHandle[_WandbLoggingActor]
|
||||
] = {}
|
||||
self._trial_logging_futures: Dict["Trial", ray.ObjectRef] = {}
|
||||
self._logging_future_to_trial: Dict[ray.ObjectRef, "Trial"] = {}
|
||||
self._trial_queues: Dict["Trial", Queue] = {}
|
||||
|
||||
def setup(self, *args, **kwargs):
|
||||
self.api_key_file = (
|
||||
os.path.expanduser(self.api_key_path) if self.api_key_path else None
|
||||
)
|
||||
_set_api_key(self.api_key_file, self.api_key)
|
||||
|
||||
self.project = _get_wandb_project(self.project)
|
||||
if not self.project:
|
||||
raise ValueError(
|
||||
"Please pass the project name as argument or through "
|
||||
f"the {WANDB_PROJECT_ENV_VAR} environment variable."
|
||||
)
|
||||
if not self.group and os.environ.get(WANDB_GROUP_ENV_VAR):
|
||||
self.group = os.environ.get(WANDB_GROUP_ENV_VAR)
|
||||
|
||||
def log_trial_start(self, trial: "Trial"):
|
||||
config = trial.config.copy()
|
||||
|
||||
config.pop("callbacks", None) # Remove callbacks
|
||||
|
||||
exclude_results = self._exclude_results.copy()
|
||||
|
||||
# Additional excludes
|
||||
exclude_results += self.excludes
|
||||
|
||||
# Log config keys on each result?
|
||||
if not self.log_config:
|
||||
exclude_results += ["config"]
|
||||
|
||||
# Fill trial ID and name
|
||||
trial_id = trial.trial_id if trial else None
|
||||
trial_name = str(trial) if trial else None
|
||||
|
||||
# Project name for Wandb
|
||||
wandb_project = self.project
|
||||
|
||||
# Grouping
|
||||
wandb_group = self.group or trial.experiment_dir_name if trial else None
|
||||
|
||||
# remove unpickleable items!
|
||||
config = _clean_log(config)
|
||||
config = {
|
||||
key: value for key, value in config.items() if key not in self.excludes
|
||||
}
|
||||
|
||||
wandb_init_kwargs = dict(
|
||||
id=trial_id,
|
||||
name=trial_name,
|
||||
resume=False,
|
||||
reinit=True,
|
||||
allow_val_change=True,
|
||||
group=wandb_group,
|
||||
project=wandb_project,
|
||||
config=config,
|
||||
)
|
||||
wandb_init_kwargs.update(self.kwargs)
|
||||
|
||||
self._start_logging_actor(trial, exclude_results, **wandb_init_kwargs)
|
||||
|
||||
def _start_logging_actor(
|
||||
self, trial: "Trial", exclude_results: List[str], **wandb_init_kwargs
|
||||
):
|
||||
# Reuse actor if one already exists.
|
||||
# This can happen if the trial is restarted.
|
||||
if trial in self._trial_logging_futures:
|
||||
return
|
||||
|
||||
if not self._remote_logger_class:
|
||||
env_vars = {}
|
||||
# API key env variable is not set if authenticating through `wandb login`
|
||||
if WANDB_ENV_VAR in os.environ:
|
||||
env_vars[WANDB_ENV_VAR] = os.environ[WANDB_ENV_VAR]
|
||||
self._remote_logger_class = ray.remote(
|
||||
num_cpus=0,
|
||||
**_force_on_current_node(),
|
||||
runtime_env={"env_vars": env_vars},
|
||||
max_restarts=-1,
|
||||
max_task_retries=-1,
|
||||
)(self._logger_actor_cls)
|
||||
|
||||
self._trial_queues[trial] = Queue(
|
||||
actor_options={
|
||||
"num_cpus": 0,
|
||||
**_force_on_current_node(),
|
||||
"max_restarts": -1,
|
||||
"max_task_retries": -1,
|
||||
}
|
||||
)
|
||||
self._trial_logging_actors[trial] = self._remote_logger_class.remote(
|
||||
logdir=trial.local_path,
|
||||
queue=self._trial_queues[trial],
|
||||
exclude=exclude_results,
|
||||
to_config=self.AUTO_CONFIG_KEYS,
|
||||
**wandb_init_kwargs,
|
||||
)
|
||||
logging_future = self._trial_logging_actors[trial].run.remote()
|
||||
self._trial_logging_futures[trial] = logging_future
|
||||
self._logging_future_to_trial[logging_future] = trial
|
||||
|
||||
def _signal_logging_actor_stop(self, trial: "Trial"):
|
||||
self._trial_queues[trial].put((_QueueItem.END, None))
|
||||
|
||||
def log_trial_result(self, iteration: int, trial: "Trial", result: Dict):
|
||||
if trial not in self._trial_logging_actors:
|
||||
self.log_trial_start(trial)
|
||||
|
||||
result = _clean_log(
|
||||
result, video_kwargs=self.video_kwargs, image_kwargs=self.image_kwargs
|
||||
)
|
||||
self._trial_queues[trial].put((_QueueItem.RESULT, result))
|
||||
|
||||
def log_trial_save(self, trial: "Trial"):
|
||||
if self.upload_checkpoints and trial.checkpoint:
|
||||
checkpoint_root = None
|
||||
if isinstance(trial.checkpoint.filesystem, pyarrow.fs.LocalFileSystem):
|
||||
checkpoint_root = trial.checkpoint.path
|
||||
|
||||
if checkpoint_root:
|
||||
self._trial_queues[trial].put((_QueueItem.CHECKPOINT, checkpoint_root))
|
||||
|
||||
def log_trial_end(self, trial: "Trial", failed: bool = False):
|
||||
self._signal_logging_actor_stop(trial=trial)
|
||||
self._cleanup_logging_actors()
|
||||
|
||||
def _cleanup_logging_actor(self, trial: "Trial"):
|
||||
del self._trial_queues[trial]
|
||||
del self._trial_logging_futures[trial]
|
||||
ray.kill(self._trial_logging_actors[trial])
|
||||
del self._trial_logging_actors[trial]
|
||||
|
||||
def _cleanup_logging_actors(self, timeout: int = 0, kill_on_timeout: bool = False):
|
||||
"""Clean up logging actors that have finished uploading to wandb.
|
||||
Waits for `timeout` seconds to collect finished logging actors.
|
||||
|
||||
Args:
|
||||
timeout: The number of seconds to wait. Defaults to 0 to clean up
|
||||
any immediate logging actors during the run.
|
||||
This is set to a timeout threshold to wait for pending uploads
|
||||
on experiment end.
|
||||
kill_on_timeout: Whether or not to kill and cleanup the logging actor if
|
||||
it hasn't finished within the timeout.
|
||||
"""
|
||||
|
||||
futures = list(self._trial_logging_futures.values())
|
||||
done, remaining = ray.wait(futures, num_returns=len(futures), timeout=timeout)
|
||||
for ready_future in done:
|
||||
finished_trial = self._logging_future_to_trial.pop(ready_future)
|
||||
self._cleanup_logging_actor(finished_trial)
|
||||
|
||||
if kill_on_timeout:
|
||||
for remaining_future in remaining:
|
||||
trial = self._logging_future_to_trial.pop(remaining_future)
|
||||
self._cleanup_logging_actor(trial)
|
||||
|
||||
def on_experiment_end(self, trials: List["Trial"], **info):
|
||||
"""Wait for the actors to finish their call to `wandb.finish`.
|
||||
This includes uploading all logs + artifacts to wandb."""
|
||||
self._cleanup_logging_actors(timeout=self._upload_timeout, kill_on_timeout=True)
|
||||
|
||||
def __del__(self):
|
||||
if ray.is_initialized():
|
||||
for trial in list(self._trial_logging_actors):
|
||||
self._signal_logging_actor_stop(trial=trial)
|
||||
|
||||
self._cleanup_logging_actors(timeout=2, kill_on_timeout=True)
|
||||
|
||||
self._trial_logging_actors = {}
|
||||
self._trial_logging_futures = {}
|
||||
self._logging_future_to_trial = {}
|
||||
self._trial_queues = {}
|
||||
@@ -0,0 +1,290 @@
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow
|
||||
|
||||
import ray
|
||||
from ray._private.dict import unflattened_lookup
|
||||
from ray.air.constants import (
|
||||
EXPR_ERROR_PICKLE_FILE,
|
||||
EXPR_PROGRESS_FILE,
|
||||
EXPR_RESULT_FILE,
|
||||
)
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@PublicAPI(stability="stable")
|
||||
class Result:
|
||||
"""The final result of a ML training run or a Tune trial.
|
||||
|
||||
This is the output produced by ``Trainer.fit``.
|
||||
``Tuner.fit`` outputs a :class:`~ray.tune.ResultGrid` that is a collection
|
||||
of ``Result`` objects.
|
||||
|
||||
This API is the recommended way to access the outputs such as:
|
||||
- checkpoints (``Result.checkpoint``)
|
||||
- the history of reported metrics (``Result.metrics_dataframe``, ``Result.metrics``)
|
||||
- errors encountered during a training run (``Result.error``)
|
||||
|
||||
The constructor is a private API -- use ``Result.from_path`` to create a result
|
||||
object from a directory.
|
||||
|
||||
Attributes:
|
||||
metrics: The latest set of reported metrics.
|
||||
checkpoint: The latest checkpoint.
|
||||
error: The execution error of the Trainable run, if the trial finishes in error.
|
||||
path: Path pointing to the result directory on persistent storage. This can
|
||||
point to a remote storage location (e.g. S3) or to a local location (path
|
||||
on the head node). The path is accessible via the result's associated
|
||||
`filesystem`. For instance, for a result stored in S3 at
|
||||
``s3://bucket/location``, ``path`` will have the value ``bucket/location``.
|
||||
metrics_dataframe: The full result dataframe of the Trainable.
|
||||
The dataframe is indexed by iterations and contains reported
|
||||
metrics. Note that the dataframe columns are indexed with the
|
||||
*flattened* keys of reported metrics, so the format of this dataframe
|
||||
may be slightly different than ``Result.metrics``, which is an unflattened
|
||||
dict of the latest set of reported metrics.
|
||||
best_checkpoints: A list of tuples of the best checkpoints and
|
||||
their associated metrics. The number of
|
||||
saved checkpoints is determined by :class:`~ray.train.CheckpointConfig`
|
||||
(by default, all checkpoints will be saved).
|
||||
"""
|
||||
|
||||
metrics: Optional[Dict[str, Any]]
|
||||
checkpoint: Optional["ray.tune.Checkpoint"]
|
||||
error: Optional[Exception]
|
||||
path: str
|
||||
metrics_dataframe: Optional["pd.DataFrame"] = None
|
||||
best_checkpoints: Optional[
|
||||
List[Tuple["ray.tune.Checkpoint", Dict[str, Any]]]
|
||||
] = None
|
||||
_storage_filesystem: Optional[pyarrow.fs.FileSystem] = None
|
||||
_items_to_repr = ["error", "metrics", "path", "filesystem", "checkpoint"]
|
||||
|
||||
@property
|
||||
def config(self) -> Optional[Dict[str, Any]]:
|
||||
"""The config associated with the result."""
|
||||
if not self.metrics:
|
||||
return None
|
||||
return self.metrics.get("config", None)
|
||||
|
||||
@property
|
||||
def filesystem(self) -> pyarrow.fs.FileSystem:
|
||||
"""Return the filesystem that can be used to access the result path.
|
||||
|
||||
Returns:
|
||||
pyarrow.fs.FileSystem implementation.
|
||||
"""
|
||||
return self._storage_filesystem or pyarrow.fs.LocalFileSystem()
|
||||
|
||||
def _repr(self, indent: int = 0) -> str:
|
||||
"""Construct the representation with specified number of space indent."""
|
||||
from ray.tune.experimental.output import BLACKLISTED_KEYS
|
||||
from ray.tune.result import AUTO_RESULT_KEYS
|
||||
|
||||
shown_attributes = {k: getattr(self, k) for k in self._items_to_repr}
|
||||
if self.error:
|
||||
shown_attributes["error"] = type(self.error).__name__
|
||||
else:
|
||||
shown_attributes.pop("error")
|
||||
|
||||
shown_attributes["filesystem"] = shown_attributes["filesystem"].type_name
|
||||
|
||||
if self.metrics:
|
||||
exclude = set(AUTO_RESULT_KEYS)
|
||||
exclude.update(BLACKLISTED_KEYS)
|
||||
shown_attributes["metrics"] = {
|
||||
k: v for k, v in self.metrics.items() if k not in exclude
|
||||
}
|
||||
|
||||
cls_indent = " " * indent
|
||||
kws_indent = " " * (indent + 2)
|
||||
|
||||
kws = [
|
||||
f"{kws_indent}{key}={value!r}" for key, value in shown_attributes.items()
|
||||
]
|
||||
kws_repr = ",\n".join(kws)
|
||||
return "{0}{1}(\n{2}\n{0})".format(cls_indent, type(self).__name__, kws_repr)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._repr(indent=0)
|
||||
|
||||
@staticmethod
|
||||
def _read_file_as_str(
|
||||
storage_filesystem: pyarrow.fs.FileSystem,
|
||||
storage_path: str,
|
||||
) -> str:
|
||||
"""Opens a file as an input stream reading all byte content sequentially and
|
||||
decoding read bytes as utf-8 string.
|
||||
|
||||
Args:
|
||||
storage_filesystem: The filesystem to use.
|
||||
storage_path: The source to open for reading.
|
||||
|
||||
Returns:
|
||||
The file contents decoded as a UTF-8 string.
|
||||
"""
|
||||
|
||||
with storage_filesystem.open_input_stream(storage_path) as f:
|
||||
return f.readall().decode()
|
||||
|
||||
@classmethod
|
||||
def from_path(
|
||||
cls,
|
||||
path: Union[str, os.PathLike],
|
||||
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
|
||||
) -> "Result":
|
||||
"""Restore a Result object from local or remote trial directory.
|
||||
|
||||
Args:
|
||||
path: A path of a trial directory on local or remote storage
|
||||
(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 path
|
||||
is assumed to be prefix-stripped already, and must be a valid path
|
||||
on the filesystem.
|
||||
|
||||
Returns:
|
||||
A :py:class:`Result` object of that trial.
|
||||
"""
|
||||
# TODO(justinvyu): Fix circular dependency.
|
||||
from ray.train import Checkpoint
|
||||
from ray.train._internal.storage import (
|
||||
_exists_at_fs_path,
|
||||
_list_at_fs_path,
|
||||
get_fs_and_path,
|
||||
)
|
||||
from ray.train.constants import CHECKPOINT_DIR_NAME
|
||||
|
||||
fs, fs_path = get_fs_and_path(path, storage_filesystem)
|
||||
if not _exists_at_fs_path(fs, fs_path):
|
||||
raise RuntimeError(f"Trial folder {fs_path} doesn't exist!")
|
||||
|
||||
# Restore metrics from result.json
|
||||
result_json_file = Path(fs_path, EXPR_RESULT_FILE).as_posix()
|
||||
progress_csv_file = Path(fs_path, EXPR_PROGRESS_FILE).as_posix()
|
||||
if _exists_at_fs_path(fs, result_json_file):
|
||||
lines = cls._read_file_as_str(fs, result_json_file).split("\n")
|
||||
json_list = [json.loads(line) for line in lines if line]
|
||||
metrics_df = pd.json_normalize(json_list, sep="/")
|
||||
latest_metrics = json_list[-1] if json_list else {}
|
||||
# Fallback to restore from progress.csv
|
||||
elif _exists_at_fs_path(fs, progress_csv_file):
|
||||
metrics_df = pd.read_csv(
|
||||
io.StringIO(cls._read_file_as_str(fs, progress_csv_file))
|
||||
)
|
||||
latest_metrics = (
|
||||
metrics_df.iloc[-1].to_dict() if not metrics_df.empty else {}
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Failed to restore the Result object: Neither {EXPR_RESULT_FILE}"
|
||||
f" nor {EXPR_PROGRESS_FILE} exists in the trial folder!"
|
||||
)
|
||||
|
||||
# Restore all checkpoints from the checkpoint folders
|
||||
checkpoint_dir_names = sorted(
|
||||
_list_at_fs_path(
|
||||
fs,
|
||||
fs_path,
|
||||
file_filter=lambda file_info: file_info.type
|
||||
== pyarrow.fs.FileType.Directory
|
||||
and file_info.base_name.startswith("checkpoint_"),
|
||||
)
|
||||
)
|
||||
|
||||
if checkpoint_dir_names:
|
||||
checkpoints = [
|
||||
Checkpoint(
|
||||
path=Path(fs_path, checkpoint_dir_name).as_posix(), filesystem=fs
|
||||
)
|
||||
for checkpoint_dir_name in checkpoint_dir_names
|
||||
]
|
||||
|
||||
metrics = []
|
||||
for checkpoint_dir_name in checkpoint_dir_names:
|
||||
metrics_corresponding_to_checkpoint = metrics_df[
|
||||
metrics_df[CHECKPOINT_DIR_NAME] == checkpoint_dir_name
|
||||
]
|
||||
if metrics_corresponding_to_checkpoint.empty:
|
||||
logger.warning(
|
||||
"Could not find metrics corresponding to "
|
||||
f"{checkpoint_dir_name}. These will default to an empty dict."
|
||||
)
|
||||
metrics.append(
|
||||
{}
|
||||
if metrics_corresponding_to_checkpoint.empty
|
||||
else metrics_corresponding_to_checkpoint.iloc[-1].to_dict()
|
||||
)
|
||||
|
||||
latest_checkpoint = checkpoints[-1]
|
||||
# TODO(justinvyu): These are ordered by checkpoint index, since we don't
|
||||
# know the metric to order these with.
|
||||
best_checkpoints = list(zip(checkpoints, metrics))
|
||||
else:
|
||||
best_checkpoints = latest_checkpoint = None
|
||||
|
||||
# Restore the trial error if it exists
|
||||
error = None
|
||||
error_file_path = Path(fs_path, EXPR_ERROR_PICKLE_FILE).as_posix()
|
||||
if _exists_at_fs_path(fs, error_file_path):
|
||||
with fs.open_input_stream(error_file_path) as f:
|
||||
error = ray.cloudpickle.load(f)
|
||||
|
||||
return Result(
|
||||
metrics=latest_metrics,
|
||||
checkpoint=latest_checkpoint,
|
||||
path=fs_path,
|
||||
_storage_filesystem=fs,
|
||||
metrics_dataframe=metrics_df,
|
||||
best_checkpoints=best_checkpoints,
|
||||
error=error,
|
||||
)
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
def get_best_checkpoint(
|
||||
self, metric: str, mode: str
|
||||
) -> Optional["ray.tune.Checkpoint"]:
|
||||
"""Get the best checkpoint from this trial based on a specific metric.
|
||||
|
||||
Any checkpoints without an associated metric value will be filtered out.
|
||||
|
||||
Args:
|
||||
metric: The key for checkpoints to order on.
|
||||
mode: One of ["min", "max"].
|
||||
|
||||
Returns:
|
||||
:class:`Checkpoint <ray.train.Checkpoint>` object, or None if there is
|
||||
no valid checkpoint associated with the metric.
|
||||
"""
|
||||
if not self.best_checkpoints:
|
||||
raise RuntimeError("No checkpoint exists in the trial directory!")
|
||||
|
||||
if mode not in ["max", "min"]:
|
||||
raise ValueError(
|
||||
f'Unsupported mode: {mode}. Please choose from ["min", "max"]!'
|
||||
)
|
||||
|
||||
op = max if mode == "max" else min
|
||||
valid_checkpoints = [
|
||||
ckpt_info
|
||||
for ckpt_info in self.best_checkpoints
|
||||
if unflattened_lookup(metric, ckpt_info[1], default=None) is not None
|
||||
]
|
||||
|
||||
if not valid_checkpoints:
|
||||
raise RuntimeError(
|
||||
f"Invalid metric name {metric}! "
|
||||
f"You may choose from the following metrics: {self.metrics.keys()}."
|
||||
)
|
||||
|
||||
return op(valid_checkpoints, key=lambda x: unflattened_lookup(metric, x[1]))[0]
|
||||
@@ -0,0 +1 @@
|
||||
from ray.train._internal.session import * # noqa: F401,F403
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.tune.experiment import Trial
|
||||
|
||||
RUNNER_TYPE = os.environ.get("RUNNER_TYPE", "trainer")
|
||||
STORAGE_PATH = os.environ.get("STORAGE_PATH", "/tmp/ray_results")
|
||||
EXP_NAME = os.environ.get("EXP_NAME", "restore_integration_test")
|
||||
CALLBACK_DUMP_FILE = os.environ.get(
|
||||
"CALLBACK_DUMP_FILE", "/tmp/callback_dump_file.json"
|
||||
)
|
||||
CSV_DATA_FILE = os.environ.get("CSV_DATA_FILE", "/tmp/dummy.csv")
|
||||
|
||||
TIME_PER_ITER_S = float(os.environ.get("TIME_PER_ITER_S", "0.5"))
|
||||
NUM_TRIALS = int(os.environ.get("NUM_TRIALS", "1"))
|
||||
MAX_CONCURRENT_TRIALS = int(os.environ.get("MAX_CONCURRENT_TRIALS", "2"))
|
||||
ITERATIONS_PER_TRIAL = int(os.environ.get("ITERATIONS_PER_TRIAL", "64"))
|
||||
|
||||
|
||||
class StatefulCallback(tune.Callback):
|
||||
def __init__(self):
|
||||
self._trial_iterations = collections.defaultdict(list)
|
||||
|
||||
def on_trial_result(
|
||||
self,
|
||||
iteration: int,
|
||||
trials: List["Trial"],
|
||||
trial: "Trial",
|
||||
result: Dict,
|
||||
**info,
|
||||
):
|
||||
self._trial_iterations[trial.trial_id].append(result["training_iteration"])
|
||||
|
||||
def on_experiment_end(self, trials: List["Trial"], **info):
|
||||
# Save callback contents to file
|
||||
with open(CALLBACK_DUMP_FILE, "w") as f:
|
||||
json.dump(self.get_state(), f, indent=2)
|
||||
|
||||
def get_state(self) -> Optional[Dict]:
|
||||
return {"trial_iters": self._trial_iterations.copy()}
|
||||
|
||||
def set_state(self, state: Dict):
|
||||
self._trial_iterations = state["trial_iters"]
|
||||
|
||||
|
||||
class StatefulSearcher(tune.search.Searcher):
|
||||
def __init__(
|
||||
self,
|
||||
metric: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
):
|
||||
super().__init__(metric=metric, mode=mode)
|
||||
self._trial_count = 0
|
||||
|
||||
def suggest(self, trial_id: str) -> Optional[Dict]:
|
||||
self._trial_count += 1
|
||||
return {"id": self._trial_count}
|
||||
|
||||
def on_trial_complete(
|
||||
self, trial_id: str, result: Optional[Dict] = None, error: bool = False
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def save(self, checkpoint_path: str):
|
||||
with open(checkpoint_path, "w") as f:
|
||||
json.dump({"trial_count": self._trial_count}, f)
|
||||
|
||||
def restore(self, checkpoint_path: str):
|
||||
with open(checkpoint_path, "r") as f:
|
||||
state = json.load(f)
|
||||
self._trial_count = state["trial_count"]
|
||||
|
||||
|
||||
def train_fn(config: dict, data: Optional[dict] = None):
|
||||
checkpoint = train.get_checkpoint()
|
||||
start = load_dict_checkpoint(checkpoint)["iteration"] + 1 if checkpoint else 1
|
||||
|
||||
training_started_marker = Path(
|
||||
os.environ.get("RUN_STARTED_MARKER", "/tmp/does-not-exist")
|
||||
)
|
||||
if training_started_marker.exists():
|
||||
# Multiple workers may be trying to delete the same marker
|
||||
try:
|
||||
training_started_marker.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
for iteration in range(start, ITERATIONS_PER_TRIAL + 1):
|
||||
time.sleep(TIME_PER_ITER_S)
|
||||
|
||||
with create_dict_checkpoint({"iteration": iteration}) as checkpoint:
|
||||
train.report({"score": random.random()}, checkpoint=checkpoint)
|
||||
|
||||
|
||||
def tuner(experiment_path: str, run_config: tune.RunConfig) -> tune.ResultGrid:
|
||||
trainable = tune.with_resources(train_fn, resources={"CPU": 1})
|
||||
trainable = tune.with_parameters(trainable, data={"dummy_data": [1, 2, 3]})
|
||||
|
||||
if tune.Tuner.can_restore(experiment_path):
|
||||
tuner = tune.Tuner.restore(
|
||||
experiment_path, trainable=trainable, resume_errored=True
|
||||
)
|
||||
else:
|
||||
tuner = tune.Tuner(
|
||||
trainable,
|
||||
run_config=run_config,
|
||||
tune_config=tune.TuneConfig(
|
||||
num_samples=8,
|
||||
max_concurrent_trials=2,
|
||||
search_alg=StatefulSearcher(),
|
||||
),
|
||||
)
|
||||
|
||||
result_grid = tuner.fit()
|
||||
return result_grid
|
||||
|
||||
|
||||
def trainer(experiment_path: str, run_config: train.RunConfig) -> train.Result:
|
||||
dataset_size = 128
|
||||
num_workers = 4
|
||||
|
||||
def train_loop_per_worker(config):
|
||||
# Wrap the other train_fn with a check for the dataset.
|
||||
assert train.get_dataset_shard("train")
|
||||
train_fn(config)
|
||||
|
||||
datasets = {
|
||||
"train": ray.data.range(dataset_size),
|
||||
"valid": ray.data.read_csv(CSV_DATA_FILE),
|
||||
}
|
||||
|
||||
if DataParallelTrainer.can_restore(experiment_path):
|
||||
trainer = DataParallelTrainer.restore(
|
||||
experiment_path,
|
||||
datasets=datasets,
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
)
|
||||
else:
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker,
|
||||
datasets=datasets,
|
||||
scaling_config=train.ScalingConfig(
|
||||
num_workers=num_workers, trainer_resources={"CPU": 0}
|
||||
),
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
result = trainer.fit()
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
experiment_path = os.path.join(STORAGE_PATH, EXP_NAME)
|
||||
|
||||
ray.init()
|
||||
|
||||
run_config = train.RunConfig(
|
||||
storage_path=STORAGE_PATH,
|
||||
name=EXP_NAME,
|
||||
checkpoint_config=train.CheckpointConfig(num_to_keep=1),
|
||||
callbacks=[StatefulCallback()],
|
||||
)
|
||||
|
||||
if RUNNER_TYPE == "tuner":
|
||||
tuner(experiment_path, run_config)
|
||||
elif RUNNER_TYPE == "trainer":
|
||||
trainer(experiment_path, run_config)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"`RUNNER_TYPE` environment var must be one of ['tuner', 'trainer']"
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.tests.conftest import pytest_runtest_makereport # noqa
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_data_context(request):
|
||||
"""Restore any DataContext changes after the test runs"""
|
||||
original = copy.deepcopy(ray.data.context.DataContext.get_current())
|
||||
yield
|
||||
ray.data.context.DataContext._set_current(original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_fallback_to_object_extension(request, restore_data_context):
|
||||
"""Disables fallback to ArrowPythonObjectType"""
|
||||
ray.data.context.DataContext.get_current().enable_fallback_to_arrow_object_ext_type = (
|
||||
False
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.air.execution._internal.barrier import Barrier
|
||||
|
||||
|
||||
def _raise(exception_type: Type[Exception] = RuntimeError, msg: Optional[str] = None):
|
||||
def _raise_exception(*args, **kwargs):
|
||||
raise exception_type(msg)
|
||||
|
||||
return _raise_exception
|
||||
|
||||
|
||||
def test_barrier_max_results():
|
||||
"""Test the `max_results` attribute.
|
||||
|
||||
- Set max_results=10
|
||||
- Assert that the barrier completion callback is not invoked with num_results<10
|
||||
- Assert that callback is invoked with num_results=10
|
||||
- Assert that callback is not invoked again when more events arrive
|
||||
- Assert that more events can arrive without triggering the callback after resetting
|
||||
"""
|
||||
barrier = Barrier(max_results=10, on_completion=_raise(AssertionError))
|
||||
|
||||
for i in range(9):
|
||||
barrier.arrive(i)
|
||||
|
||||
assert not barrier.completed
|
||||
|
||||
# Will trigger the on_completion callback
|
||||
with pytest.raises(AssertionError):
|
||||
barrier.arrive(10)
|
||||
|
||||
assert barrier.completed
|
||||
|
||||
assert barrier.num_results == 10
|
||||
|
||||
# Further events will not trigger callback again
|
||||
barrier.arrive(11)
|
||||
|
||||
barrier.reset()
|
||||
|
||||
assert not barrier.completed
|
||||
|
||||
# After flushing more events can arrive
|
||||
barrier.arrive(12)
|
||||
assert barrier.num_results == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,266 @@
|
||||
import random
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air import ResourceRequest
|
||||
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
|
||||
from ray.air.execution._internal import Barrier
|
||||
from ray.air.execution._internal.actor_manager import RayActorManager
|
||||
from ray.air.execution._internal.tracked_actor import TrackedActor
|
||||
from ray.exceptions import RayActorError
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@ray.remote
|
||||
class Actor:
|
||||
"""Simple actor for testing an execution flow.
|
||||
|
||||
This actor can fail in these ways:
|
||||
|
||||
1. On init if ``actor_init_kill`` is passed as a kwarg
|
||||
2. On setup_1() if ``actor_setup_kill`` is passed as a kwarg (RayActorError)
|
||||
3. On setup_1() if ``actor_setup_fail`` is passed as a kwarg (RayTaskError)
|
||||
4. On train() if ``actor_train_kill`` is passed as a kwarg (RayTaskError)
|
||||
5. On train() if ``actor_train_fail`` is passed as a kwarg (RayTaskError)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
if self.kwargs.get("actor_init_kill"):
|
||||
raise RuntimeError("INIT")
|
||||
|
||||
def get_kwargs(self):
|
||||
return self.kwargs
|
||||
|
||||
def setup_1(self):
|
||||
if self.kwargs.get("actor_setup_kill"):
|
||||
raise SystemExit
|
||||
|
||||
if self.kwargs.get("actor_setup_fail"):
|
||||
raise RuntimeError("Setup")
|
||||
|
||||
return True
|
||||
|
||||
def setup_2(self):
|
||||
return True
|
||||
|
||||
def train(self, value: float) -> float:
|
||||
if value == 4:
|
||||
if self.kwargs.get("actor_train_kill"):
|
||||
# SystemExit will invoke a RayActorError
|
||||
raise SystemExit
|
||||
|
||||
if self.kwargs.get("actor_train_fail"):
|
||||
# RuntimeError will invoke a RayTaskError
|
||||
raise RuntimeError("TASK")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class TrainFlow:
|
||||
"""This is a Ray Train-like execution flow.
|
||||
|
||||
- We want to run 4 actors in total ("trials")
|
||||
- Each actor runs two init functions
|
||||
- We train all actors in parallel for 10 iterations
|
||||
- Errors can come up on actor construction, in the init functions,
|
||||
or during training
|
||||
- When an actor fails, restart that actor
|
||||
- When a task fails, stop actor, and restart
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, actor_manager: RayActorManager, errors: Optional[List[str]] = None
|
||||
):
|
||||
self._actor_manager = actor_manager
|
||||
self._finished = False
|
||||
|
||||
self._actors_to_run = 4
|
||||
self._tracked_actors = []
|
||||
self._actors_stopped = 0
|
||||
|
||||
self._actors_to_replace = set()
|
||||
|
||||
self._ready_actors = set()
|
||||
self._training_barrier = Barrier(
|
||||
max_results=self._actors_to_run,
|
||||
on_completion=self.training_barrier_completed,
|
||||
)
|
||||
self._restart_training = None
|
||||
|
||||
self._training_iter = 0
|
||||
self._results = []
|
||||
|
||||
self._errors = errors
|
||||
|
||||
def setup_actors(self):
|
||||
for actor_id in range(self._actors_to_run):
|
||||
error_kwargs = {}
|
||||
if self._errors:
|
||||
error = random.choice(self._errors)
|
||||
error_kwargs[error] = True
|
||||
|
||||
print("Actor", actor_id, "will be failing with", error_kwargs)
|
||||
|
||||
tracked_actor = self._actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={"id": actor_id, **error_kwargs},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=self.actor_started,
|
||||
on_stop=self.actor_stopped,
|
||||
on_error=self.actor_error,
|
||||
)
|
||||
self._tracked_actors.append(tracked_actor)
|
||||
|
||||
def actor_started(self, tracked_actor: TrackedActor):
|
||||
self._actor_manager.schedule_actor_task(
|
||||
tracked_actor,
|
||||
"setup_1",
|
||||
on_error=self.setup_error,
|
||||
on_result=self.setup_1_result,
|
||||
)
|
||||
|
||||
def actor_stopped(self, tracked_actor: TrackedActor):
|
||||
self._ready_actors.discard(tracked_actor)
|
||||
|
||||
if tracked_actor in self._actors_to_replace:
|
||||
self._replace_actor(tracked_actor=tracked_actor)
|
||||
else:
|
||||
self._actors_stopped += 1
|
||||
self._finished = self._actors_stopped >= self._actors_to_run
|
||||
|
||||
def actor_error(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
self._ready_actors.discard(tracked_actor)
|
||||
self._replace_actor(tracked_actor=tracked_actor)
|
||||
|
||||
def _replace_actor(self, tracked_actor: TrackedActor):
|
||||
actor_index = self._tracked_actors.index(tracked_actor)
|
||||
|
||||
replacement_actor = self._actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={"id": actor_index},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=self.actor_started,
|
||||
on_stop=self.actor_stopped,
|
||||
on_error=self.actor_error,
|
||||
)
|
||||
|
||||
self._tracked_actors[actor_index] = replacement_actor
|
||||
|
||||
def setup_1_result(self, tracked_actor: TrackedActor, result: Any):
|
||||
self._actor_manager.schedule_actor_task(
|
||||
tracked_actor,
|
||||
"setup_2",
|
||||
on_error=self.setup_error,
|
||||
on_result=self.setup_2_result,
|
||||
)
|
||||
|
||||
def setup_2_result(self, tracked_actor: TrackedActor, result: Any):
|
||||
self._ready_actors.add(tracked_actor)
|
||||
|
||||
if len(self._ready_actors) == self._actors_to_run:
|
||||
self.continue_training()
|
||||
|
||||
def setup_error(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
if isinstance(exception, RayActorError):
|
||||
return
|
||||
|
||||
self._actors_to_replace.add(tracked_actor)
|
||||
self._actor_manager.remove_actor(tracked_actor)
|
||||
|
||||
def continue_training(self):
|
||||
if self._restart_training:
|
||||
self._training_iter = self._restart_training
|
||||
else:
|
||||
self._training_iter += 1
|
||||
|
||||
self._training_barrier.reset()
|
||||
self._actor_manager.schedule_actor_tasks(
|
||||
self._tracked_actors,
|
||||
"train",
|
||||
args=(self._training_iter,),
|
||||
on_result=self._training_barrier.arrive,
|
||||
on_error=self.training_error,
|
||||
)
|
||||
|
||||
def training_barrier_completed(self, barrier: Barrier):
|
||||
self._results.append([res for _, res in barrier.get_results()])
|
||||
self._restart_training = None
|
||||
|
||||
# If less than 10 epochs, continue training
|
||||
if self._training_iter < 10:
|
||||
return self.continue_training()
|
||||
|
||||
# Else, training finished
|
||||
for tracked_actor in self._tracked_actors:
|
||||
self._actor_manager.remove_actor(tracked_actor)
|
||||
|
||||
def training_error(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
self._restart_training = self._training_iter
|
||||
|
||||
if isinstance(exception, RayActorError):
|
||||
return
|
||||
|
||||
self._actors_to_replace.add(tracked_actor)
|
||||
self._ready_actors.discard(tracked_actor)
|
||||
self._actor_manager.remove_actor(tracked_actor)
|
||||
|
||||
def run(self):
|
||||
self.setup_actors()
|
||||
|
||||
while not self._finished:
|
||||
self._actor_manager.next()
|
||||
|
||||
def get_results(self) -> List[List[float]]:
|
||||
return self._results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"errors",
|
||||
[
|
||||
None,
|
||||
"actor_init_kill",
|
||||
"actor_setup_kill",
|
||||
"actor_setup_fail",
|
||||
"actor_train_kill",
|
||||
"actor_train_fail",
|
||||
# Chaos - every actor fails somehow, but in different ways
|
||||
[
|
||||
"actor_init_kill",
|
||||
"actor_setup_kill",
|
||||
"actor_setup_fail",
|
||||
"actor_train_kill",
|
||||
"actor_train_fail",
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_e2e(ray_start_4_cpus, resource_manager_cls, errors):
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
if errors and isinstance(errors, str):
|
||||
errors = [errors]
|
||||
|
||||
flow = TrainFlow(actor_manager=actor_manager, errors=errors)
|
||||
flow.run()
|
||||
|
||||
results = flow.get_results()
|
||||
|
||||
assert results == [[i] * 4 for i in range(1, 11)], results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,227 @@
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air import ResourceRequest
|
||||
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
|
||||
from ray.air.execution._internal.actor_manager import RayActorManager
|
||||
from ray.air.execution._internal.tracked_actor import TrackedActor
|
||||
from ray.exceptions import RayActorError
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@ray.remote
|
||||
class Actor:
|
||||
"""Simple actor for testing an execution flow.
|
||||
|
||||
This actor can fail in three ways:
|
||||
|
||||
1. On init if ``actor_error_init`` is passed as a kwarg
|
||||
2. On run() if ``actor_error_task`` is passed as a kwarg (RayActorError)
|
||||
3. On run() if ``task_error`` is passed as a kwarg (RayTaskError)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
if self.kwargs.get("actor_error_init"):
|
||||
raise RuntimeError("INIT")
|
||||
|
||||
def get_kwargs(self):
|
||||
return self.kwargs
|
||||
|
||||
def run(self, value: float) -> float:
|
||||
if value == 2:
|
||||
if self.kwargs.get("actor_error_task"):
|
||||
# SystemExit will invoke a RayActorError
|
||||
raise SystemExit
|
||||
|
||||
if self.kwargs.get("task_error"):
|
||||
# RuntimeError will invoke a RayTaskError
|
||||
raise RuntimeError("TASK")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class TuneFlow:
|
||||
"""This is a Ray Tune-like execution flow.
|
||||
|
||||
- We want to run 10 actors in total ("trials")
|
||||
- Each actor collects 11 results sequentially
|
||||
- We schedule up to 6 actors at the same time
|
||||
- Every step, we see if we should add any new actors
|
||||
- Otherwise, we just yield control to the event manager and process events one
|
||||
by one
|
||||
- When an actor is started, start training flow
|
||||
- When a result comes in, schedule next future
|
||||
- If this is the 11th result, stop actor
|
||||
- When the last actor is stopped, set state to finished
|
||||
|
||||
- When an actor fails, restart
|
||||
- When a task fails, stop actor, and restart
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, actor_manager: RayActorManager, errors: Optional[List[str]] = None
|
||||
):
|
||||
self._actor_manager = actor_manager
|
||||
self._finished = False
|
||||
|
||||
self._actors_to_run = 10
|
||||
self._actors_started = 0
|
||||
self._actors_stopped = 0
|
||||
self._max_pending = 6
|
||||
|
||||
self._actor_to_id = {}
|
||||
self._results = defaultdict(list)
|
||||
|
||||
self._errors = errors
|
||||
|
||||
def maybe_add_actors(self):
|
||||
if self._actors_started >= self._actors_to_run:
|
||||
return
|
||||
|
||||
if self._actor_manager.num_pending_actors >= self._max_pending:
|
||||
return
|
||||
|
||||
error_kwargs = {}
|
||||
if self._errors:
|
||||
error = random.choice(self._errors)
|
||||
error_kwargs[error] = True
|
||||
|
||||
actor_id = self._actors_started
|
||||
|
||||
print("Actor", actor_id, "will be failing with", error_kwargs)
|
||||
|
||||
tracked_actor = self._actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={"id": actor_id, **error_kwargs},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=self.actor_started,
|
||||
on_stop=self.actor_stopped,
|
||||
on_error=self.actor_error,
|
||||
)
|
||||
self._actor_to_id[tracked_actor] = actor_id
|
||||
|
||||
self._actors_started += 1
|
||||
|
||||
def actor_started(self, tracked_actor: TrackedActor):
|
||||
self._actor_manager.schedule_actor_task(
|
||||
tracked_actor,
|
||||
"run",
|
||||
kwargs={"value": 0},
|
||||
on_error=self.task_error,
|
||||
on_result=self.task_result,
|
||||
)
|
||||
|
||||
def actor_stopped(self, tracked_actor: TrackedActor):
|
||||
self._actors_stopped += 1
|
||||
self._finished = self._actors_stopped >= self._actors_to_run
|
||||
|
||||
def actor_error(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
actor_id = self._actor_to_id.pop(tracked_actor)
|
||||
|
||||
replacement_actor = self._actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={
|
||||
"id": actor_id,
|
||||
"actor_error_init": False,
|
||||
"actor_error_task": False,
|
||||
"task_error": False,
|
||||
},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=self.actor_started,
|
||||
on_stop=self.actor_stopped,
|
||||
on_error=self.actor_error,
|
||||
)
|
||||
|
||||
self._actor_to_id[replacement_actor] = actor_id
|
||||
|
||||
def task_result(self, tracked_actor: TrackedActor, result: float):
|
||||
actor_id = self._actor_to_id[tracked_actor]
|
||||
self._results[actor_id].append(result)
|
||||
|
||||
if result == 10:
|
||||
self._actor_manager.remove_actor(tracked_actor)
|
||||
else:
|
||||
self._actor_manager.schedule_actor_task(
|
||||
tracked_actor,
|
||||
"run",
|
||||
kwargs={"value": result + 1},
|
||||
on_result=self.task_result,
|
||||
on_error=self.task_error,
|
||||
)
|
||||
|
||||
def task_error(self, tracked_actor: TrackedActor, exception: Exception):
|
||||
if isinstance(exception, RayActorError):
|
||||
return
|
||||
|
||||
self._actors_stopped -= 1 # account for extra stop
|
||||
self._actor_manager.remove_actor(tracked_actor)
|
||||
actor_id = self._actor_to_id.pop(tracked_actor)
|
||||
|
||||
replacement_actor = self._actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={
|
||||
"id": actor_id,
|
||||
"actor_error_init": False,
|
||||
"actor_error_task": False,
|
||||
"task_error": False,
|
||||
},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=self.actor_started,
|
||||
on_stop=self.actor_stopped,
|
||||
on_error=self.actor_error,
|
||||
)
|
||||
self._actor_to_id[replacement_actor] = actor_id
|
||||
|
||||
def run(self):
|
||||
while not self._finished:
|
||||
self.maybe_add_actors()
|
||||
self._actor_manager.next(timeout=1)
|
||||
|
||||
def get_results(self) -> Dict[int, List[float]]:
|
||||
return self._results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"errors",
|
||||
[
|
||||
None,
|
||||
"actor_error_init",
|
||||
"actor_error_task",
|
||||
"task_error",
|
||||
# Chaos - every actor fails somehow, but in different ways
|
||||
["actor_error_init", "actor_error_task", "task_error"],
|
||||
],
|
||||
)
|
||||
def test_e2e(ray_start_4_cpus, resource_manager_cls, errors):
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
if errors and isinstance(errors, str):
|
||||
errors = [errors]
|
||||
|
||||
flow = TuneFlow(actor_manager=actor_manager, errors=errors)
|
||||
flow.run()
|
||||
|
||||
results = flow.get_results()
|
||||
|
||||
assert all(res[-1] == 10 for res in results.values()), results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,224 @@
|
||||
import time
|
||||
from typing import Any, Type
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air.execution._internal import Barrier
|
||||
from ray.air.execution._internal.event_manager import RayEventManager
|
||||
from ray.exceptions import RayTaskError
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@ray.remote
|
||||
def succeeding(ret: Any = None) -> Any:
|
||||
return ret
|
||||
|
||||
|
||||
@ray.remote
|
||||
def failing(exc: Type[Exception], *args) -> None:
|
||||
raise exc(*args)
|
||||
|
||||
|
||||
@ray.remote
|
||||
def sleeping(seconds: int, result: Any) -> Any:
|
||||
time.sleep(seconds)
|
||||
return result
|
||||
|
||||
|
||||
def test_track_future_success(ray_start_4_cpus):
|
||||
"""Schedule a future that return successfully.
|
||||
|
||||
Check that the on_result callback was triggered.
|
||||
"""
|
||||
event_manager = RayEventManager()
|
||||
|
||||
seen = set()
|
||||
|
||||
def on_result(result: Any):
|
||||
seen.add(result)
|
||||
|
||||
event_manager.track_future(succeeding.remote("a"), on_result=on_result)
|
||||
|
||||
event_manager.wait()
|
||||
assert "a" in seen
|
||||
|
||||
assert not event_manager._tracked_futures
|
||||
|
||||
|
||||
def test_track_future_success_no_callback(ray_start_4_cpus):
|
||||
"""Schedule a future that return successfully.
|
||||
|
||||
Check that passing no callback still succeeds.
|
||||
"""
|
||||
event_manager = RayEventManager()
|
||||
|
||||
event_manager.track_future(succeeding.remote("a"))
|
||||
|
||||
event_manager.wait()
|
||||
|
||||
assert not event_manager._tracked_futures
|
||||
|
||||
|
||||
def test_track_future_error(ray_start_4_cpus):
|
||||
"""Schedule a future that fails.
|
||||
|
||||
Check that the on_error callback was triggered.
|
||||
"""
|
||||
event_manager = RayEventManager()
|
||||
|
||||
seen = set()
|
||||
|
||||
class CustomError(RuntimeError):
|
||||
pass
|
||||
|
||||
def on_error(exception: Exception):
|
||||
seen.add(exception)
|
||||
|
||||
event_manager.track_future(failing.remote(CustomError), on_error=on_error)
|
||||
|
||||
event_manager.wait()
|
||||
assert isinstance(seen.pop(), CustomError)
|
||||
|
||||
assert not event_manager._tracked_futures
|
||||
|
||||
|
||||
def test_track_future_error_no_callback(ray_start_4_cpus):
|
||||
"""Schedule a future that fails.
|
||||
|
||||
Check that passing no callback raises the original error.
|
||||
"""
|
||||
event_manager = RayEventManager()
|
||||
|
||||
event_manager.track_future(failing.remote(RuntimeError))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
event_manager.wait()
|
||||
|
||||
assert not event_manager._tracked_futures
|
||||
|
||||
|
||||
@pytest.mark.parametrize("results_per_wait", [None, 1, 5, 10, 100])
|
||||
def test_many_futures(ray_start_4_cpus, results_per_wait):
|
||||
"""Schedule 500 succeeding and failing futures.
|
||||
|
||||
Check that the callbacks get triggered correctly, independent of the number
|
||||
of results we await per call to RayEventManager.wait().
|
||||
"""
|
||||
num_futures = 500
|
||||
|
||||
event_manager = RayEventManager()
|
||||
|
||||
seen_results = set()
|
||||
seen_errors = set()
|
||||
|
||||
def on_result(result: Any):
|
||||
seen_results.add(result)
|
||||
|
||||
def on_error(exception: RayTaskError):
|
||||
seen_errors.add(exception.cause.args[0])
|
||||
|
||||
for i in range(num_futures):
|
||||
event_manager.track_futures(
|
||||
[
|
||||
succeeding.remote("a" + str(i)),
|
||||
failing.remote(RuntimeError, "b" + str(i)),
|
||||
],
|
||||
on_result=on_result,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
while event_manager.num_futures > 0:
|
||||
event_manager.wait(num_results=results_per_wait)
|
||||
|
||||
for i in range(num_futures):
|
||||
assert "a" + str(i) in seen_results
|
||||
assert "b" + str(i) in seen_errors
|
||||
|
||||
|
||||
def test_timeout(ray_start_4_cpus):
|
||||
"""Test the timeout parameter.
|
||||
|
||||
Start 4 tasks: Two succeed immediately, two after 1 second.
|
||||
|
||||
After waiting for 0.5 seconds, the first two tasks should have returned.
|
||||
After waiting for up to 5 seconds, the other two tasks should have returned.
|
||||
But because the tasks take only 0.5 seconds to run, we should have waited
|
||||
way less than 5 seconds.
|
||||
"""
|
||||
event_manager = RayEventManager()
|
||||
|
||||
seen = set()
|
||||
|
||||
def on_result(result: Any):
|
||||
seen.add(result)
|
||||
|
||||
event_manager.track_futures(
|
||||
[
|
||||
succeeding.remote("a"),
|
||||
succeeding.remote("b"),
|
||||
sleeping.remote(1, "c"),
|
||||
sleeping.remote(1, "d"),
|
||||
],
|
||||
on_result=on_result,
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
event_manager.wait(num_results=None, timeout=0.5)
|
||||
assert "a" in seen
|
||||
assert "b" in seen
|
||||
assert "c" not in seen
|
||||
assert "d" not in seen
|
||||
|
||||
event_manager.wait(num_results=None, timeout=5)
|
||||
taken = time.monotonic() - start
|
||||
|
||||
assert "c" in seen
|
||||
assert "d" in seen
|
||||
|
||||
# Should have returned much earlier than after 5 seconds
|
||||
assert taken < 3
|
||||
|
||||
assert not event_manager._tracked_futures
|
||||
|
||||
|
||||
def test_task_barrier(ray_start_4_cpus):
|
||||
event_manager = RayEventManager()
|
||||
|
||||
seen = set()
|
||||
|
||||
def on_completion(barrier: Barrier):
|
||||
seen.update(barrier.get_results())
|
||||
|
||||
barrier = Barrier(max_results=4, on_completion=on_completion)
|
||||
|
||||
event_manager.track_futures(
|
||||
[
|
||||
succeeding.remote("a"),
|
||||
succeeding.remote("b"),
|
||||
succeeding.remote("c"),
|
||||
succeeding.remote("d"),
|
||||
sleeping.remote(2, "e"),
|
||||
],
|
||||
on_result=barrier.arrive,
|
||||
)
|
||||
|
||||
event_manager.wait(num_results=4)
|
||||
|
||||
assert "a" in seen
|
||||
assert "b" in seen
|
||||
assert "c" in seen
|
||||
assert "d" in seen
|
||||
assert "e" not in seen
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,178 @@
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air.execution.resources.fixed import FixedResourceManager
|
||||
from ray.air.execution.resources.request import ResourceRequest
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
|
||||
REQUEST_2_CPU = ResourceRequest([{"CPU": 2}])
|
||||
REQUEST_4_CPU = ResourceRequest([{"CPU": 4}])
|
||||
REQUEST_1_2_CPU = ResourceRequest([{"CPU": 1}, {"CPU": 2}])
|
||||
REQUEST_0_2_CPU = ResourceRequest([{"CPU": 0}, {"CPU": 2}])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_acquire_return_resources(ray_start_4_cpus):
|
||||
manager = FixedResourceManager(total_resources={"CPU": 4})
|
||||
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
assert not manager.has_resources_ready(REQUEST_4_CPU)
|
||||
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
manager.request_resources(REQUEST_4_CPU)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_4_CPU)
|
||||
|
||||
ready_2 = manager.acquire_resources(REQUEST_2_CPU)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_2_CPU)
|
||||
assert not manager.has_resources_ready(REQUEST_4_CPU)
|
||||
|
||||
manager.free_resources(ready_2)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_4_CPU)
|
||||
|
||||
|
||||
def test_numerical_error(ray_start_4_cpus):
|
||||
"""Make sure we don't run into numerical errors when using fractional resources.
|
||||
|
||||
Legacy test: test_trial_runner::TrialRunnerTest::testResourceNumericalError
|
||||
"""
|
||||
manager = FixedResourceManager(
|
||||
total_resources={"CPU": 0.99, "GPU": 0.99, "a": 0.99}
|
||||
)
|
||||
resource_request = ResourceRequest([{"CPU": 0.33, "GPU": 0.33, "a": 0.33}])
|
||||
|
||||
for i in range(3):
|
||||
manager.request_resources(resource_request)
|
||||
assert manager.acquire_resources(
|
||||
resource_request=resource_request
|
||||
), manager._available_resources
|
||||
|
||||
assert manager._available_resources["CPU"] == 0
|
||||
assert manager._available_resources["GPU"] == 0
|
||||
assert manager._available_resources["a"] == 0
|
||||
|
||||
|
||||
def test_bind_two_bundles(ray_start_4_cpus):
|
||||
"""Test that binding two remote objects to a ready resource works.
|
||||
|
||||
- Request resources with 2 bundles (1 CPU and 2 CPUs)
|
||||
- Bind two remote tasks to these bundles, execute
|
||||
- Assert that resource allocation returns the correct resources: 1 CPU and 2 CPUs
|
||||
"""
|
||||
manager = FixedResourceManager()
|
||||
manager.request_resources(REQUEST_1_2_CPU)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_1_2_CPU)
|
||||
|
||||
@ray.remote
|
||||
def get_assigned_resources():
|
||||
return ray.get_runtime_context().get_assigned_resources()
|
||||
|
||||
acq = manager.acquire_resources(REQUEST_1_2_CPU)
|
||||
[av1] = acq.annotate_remote_entities([get_assigned_resources])
|
||||
|
||||
res1 = ray.get(av1.remote())
|
||||
|
||||
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 1
|
||||
|
||||
[av1, av2] = acq.annotate_remote_entities(
|
||||
[get_assigned_resources, get_assigned_resources]
|
||||
)
|
||||
|
||||
res1, res2 = ray.get([av1.remote(), av2.remote()])
|
||||
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 1
|
||||
assert sum(v for k, v in res2.items() if k.startswith("CPU")) == 2
|
||||
|
||||
|
||||
def test_bind_empty_head_bundle(ray_start_4_cpus):
|
||||
"""Test that binding two remote objects to a ready resource works with empty head.
|
||||
|
||||
- Request resources with 2 bundles (0 CPU and 2 CPUs)
|
||||
- Bind two remote tasks to these bundles, execute
|
||||
- Assert that resource allocation returns the correct resources: 0 CPU and 2 CPUs
|
||||
"""
|
||||
manager = FixedResourceManager()
|
||||
assert REQUEST_0_2_CPU.head_bundle_is_empty
|
||||
manager.request_resources(REQUEST_0_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_0_2_CPU)
|
||||
|
||||
@ray.remote
|
||||
def get_assigned_resources():
|
||||
return ray.get_runtime_context().get_assigned_resources()
|
||||
|
||||
acq = manager.acquire_resources(REQUEST_0_2_CPU)
|
||||
[av1] = acq.annotate_remote_entities([get_assigned_resources])
|
||||
|
||||
res1 = ray.get(av1.remote())
|
||||
|
||||
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 0
|
||||
|
||||
[av1, av2] = acq.annotate_remote_entities(
|
||||
[get_assigned_resources, get_assigned_resources]
|
||||
)
|
||||
|
||||
res1, res2 = ray.get([av1.remote(), av2.remote()])
|
||||
assert sum(v for k, v in res1.items() if k.startswith("CPU")) == 0
|
||||
assert sum(v for k, v in res2.items() if k.startswith("CPU")) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["STRICT_PACK", "PACK", "SPREAD", "STRICT_SPREAD"])
|
||||
def test_strategy(ray_start_4_cpus, strategy):
|
||||
"""The fixed resoure manager does not support STRICT placement strategies."""
|
||||
manager = FixedResourceManager()
|
||||
|
||||
req = ResourceRequest([{"CPU": 2}], strategy=strategy)
|
||||
|
||||
if strategy.startswith("STRICT_"):
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.request_resources(req)
|
||||
else:
|
||||
manager.request_resources(req)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["STRICT_PACK", "PACK", "SPREAD", "STRICT_SPREAD"])
|
||||
def test_strategy_nested(ray_start_4_cpus, strategy):
|
||||
"""The fixed resoure manager does not support STRICT_SPREAD within a PG."""
|
||||
|
||||
@ray.remote
|
||||
def nested_test():
|
||||
manager = FixedResourceManager()
|
||||
|
||||
req = ResourceRequest([{"CPU": 2}], strategy=strategy)
|
||||
|
||||
if strategy == "STRICT_SPREAD":
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.request_resources(req)
|
||||
else:
|
||||
manager.request_resources(req)
|
||||
|
||||
pg = ray.util.placement_group([{"CPU": 2}])
|
||||
ray.wait([pg.ready()])
|
||||
|
||||
try:
|
||||
ray.get(
|
||||
nested_test.options(
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(
|
||||
placement_group=pg, placement_group_capture_child_tasks=True
|
||||
)
|
||||
).remote()
|
||||
)
|
||||
finally:
|
||||
ray.util.remove_placement_group(pg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,409 @@
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air.execution.resources.placement_group import PlacementGroupResourceManager
|
||||
from ray.air.execution.resources.request import ResourceRequest
|
||||
|
||||
REQUEST_2_CPU = ResourceRequest([{"CPU": 2}])
|
||||
REQUEST_1_2_CPU = ResourceRequest([{"CPU": 1}, {"CPU": 2}])
|
||||
REQUEST_0_2_CPU = ResourceRequest([{"CPU": 0}, {"CPU": 2}])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _count_pg_states():
|
||||
counter = Counter()
|
||||
for _, pg_info in ray.util.placement_group_table().items():
|
||||
counter[pg_info["state"]] += 1
|
||||
return counter
|
||||
|
||||
|
||||
def test_request_cancel_resources(ray_start_4_cpus):
|
||||
"""Test that canceling a resource request clears the PG futures.
|
||||
|
||||
- Create request
|
||||
- Assert actual PG is created
|
||||
- Cancel request
|
||||
- Assert staging future is removed
|
||||
- Assert actual PG is removed
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
|
||||
# Could be pending or created
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["PENDING"] + pg_states["CREATED"] == 1
|
||||
assert pg_states["REMOVED"] == 0
|
||||
|
||||
assert manager.get_resource_futures()
|
||||
|
||||
manager.cancel_resource_request(REQUEST_2_CPU)
|
||||
|
||||
assert not manager.get_resource_futures()
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["PENDING"] + pg_states["CREATED"] == 0
|
||||
assert pg_states["REMOVED"] == 1
|
||||
|
||||
|
||||
def test_acquire_return_resources(ray_start_4_cpus):
|
||||
"""Tests that acquiring and returning resources works.
|
||||
|
||||
- At the start, no resources should be ready (no PG scheduled)
|
||||
- Request resources for 2 CPUs
|
||||
- (wait until they are ready)
|
||||
- Assert that these 2 CPUs are available to be acquired
|
||||
- Acquire
|
||||
- Assert that there are no 2 CPU resources available anymore
|
||||
- Free resources
|
||||
- Assert that the 2 CPU resources are still not available (no new request)
|
||||
- This is also tested in includes test_request_cancel_resources
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
# Request PG
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
|
||||
# Wait until ready
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
# PG exists
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 1
|
||||
assert pg_states["REMOVED"] == 0
|
||||
|
||||
# Acquire PG
|
||||
acquired = manager.acquire_resources(REQUEST_2_CPU)
|
||||
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
# Free resources
|
||||
manager.free_resources(acquired)
|
||||
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
# PG still exists
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 0
|
||||
assert pg_states["REMOVED"] == 1
|
||||
|
||||
|
||||
def test_request_pending(ray_start_4_cpus):
|
||||
"""Test that requesting too many resources leads to pending PGs.
|
||||
|
||||
- Cluster of 4 CPUs
|
||||
- Request 3 PGs a 2 CPUs
|
||||
- Acquire 2 PGs
|
||||
- Assert no resources are available anymore
|
||||
- Return both PGs
|
||||
- Assert resources are available again
|
||||
- Cancel request
|
||||
- Assert no resources are available again
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
|
||||
# Wait until some are ready
|
||||
ray.wait(manager.get_resource_futures(), num_returns=2)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_2_CPU)
|
||||
assert len(manager.get_resource_futures()) == 1
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 2
|
||||
assert pg_states["PENDING"] == 1
|
||||
assert pg_states["REMOVED"] == 0
|
||||
|
||||
acq1 = manager.acquire_resources(REQUEST_2_CPU)
|
||||
acq2 = manager.acquire_resources(REQUEST_2_CPU)
|
||||
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
manager.free_resources(acq1)
|
||||
manager.free_resources(acq2)
|
||||
|
||||
# Third PG becomes ready
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
assert manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 1
|
||||
assert pg_states["PENDING"] == 0
|
||||
assert pg_states["REMOVED"] == 2
|
||||
|
||||
manager.cancel_resource_request(REQUEST_2_CPU)
|
||||
assert not manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 0
|
||||
assert pg_states["PENDING"] == 0
|
||||
assert pg_states["REMOVED"] == 3
|
||||
|
||||
|
||||
def test_acquire_unavailable(ray_start_4_cpus):
|
||||
"""Test that acquiring resources that are not available returns None.
|
||||
|
||||
- Try to acquire
|
||||
- Assert this does not work
|
||||
- Request resources
|
||||
- Wait until ready
|
||||
- Acquire
|
||||
- Assert this did work
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
assert not manager.acquire_resources(REQUEST_2_CPU)
|
||||
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
assert manager.acquire_resources(REQUEST_2_CPU)
|
||||
|
||||
|
||||
def test_bind_two_bundles(ray_start_4_cpus):
|
||||
"""Test that binding two remote objects to a ready resource works.
|
||||
|
||||
- Request PG with 2 bundles (1 CPU and 2 CPUs)
|
||||
- Bind two remote tasks to these bundles, execute
|
||||
- Assert that resource allocation returns the correct resources: 1 CPU and 2 CPUs
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
manager.request_resources(REQUEST_1_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_1_2_CPU)
|
||||
|
||||
@ray.remote
|
||||
def get_assigned_resources():
|
||||
return ray.get_runtime_context().get_assigned_resources()
|
||||
|
||||
acq = manager.acquire_resources(REQUEST_1_2_CPU)
|
||||
[av1] = acq.annotate_remote_entities([get_assigned_resources])
|
||||
|
||||
res1 = ray.get(av1.remote())
|
||||
|
||||
assert res1 == {"CPU": 1}
|
||||
|
||||
[av1, av2] = acq.annotate_remote_entities(
|
||||
[get_assigned_resources, get_assigned_resources]
|
||||
)
|
||||
|
||||
res1, res2 = ray.get([av1.remote(), av2.remote()])
|
||||
assert res1 == {"CPU": 1}
|
||||
assert res2 == {"CPU": 2}
|
||||
|
||||
|
||||
def test_bind_empty_head_bundle(ray_start_4_cpus):
|
||||
"""Test that binding two remote objects to a ready resource works with empty head.
|
||||
|
||||
- Request PG with 2 bundles (0 CPU and 2 CPUs)
|
||||
- Bind two remote tasks to these bundles, execute
|
||||
- Assert that resource allocation returns the correct resources: 0 CPU and 2 CPUs
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
assert REQUEST_0_2_CPU.head_bundle_is_empty
|
||||
manager.request_resources(REQUEST_0_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_0_2_CPU)
|
||||
|
||||
@ray.remote
|
||||
def get_assigned_resources():
|
||||
return ray.get_runtime_context().get_assigned_resources()
|
||||
|
||||
acq = manager.acquire_resources(REQUEST_0_2_CPU)
|
||||
[av1] = acq.annotate_remote_entities([get_assigned_resources])
|
||||
|
||||
res1 = ray.get(av1.remote())
|
||||
|
||||
assert res1 == {}
|
||||
|
||||
[av1, av2] = acq.annotate_remote_entities(
|
||||
[get_assigned_resources, get_assigned_resources]
|
||||
)
|
||||
|
||||
res1, res2 = ray.get([av1.remote(), av2.remote()])
|
||||
assert res1 == {}
|
||||
assert res2 == {"CPU": 2}
|
||||
|
||||
|
||||
def test_capture_child_tasks(ray_start_4_cpus):
|
||||
"""Test that child tasks are captured when creating placement groups.
|
||||
|
||||
- Request PG with 2 bundles (1 CPU and 2 CPUs)
|
||||
- Bind a remote task that needs 2 CPUs to run
|
||||
- Assert that it can be scheduled from within the first bundle
|
||||
|
||||
This is only the case if child tasks are captured in the placement groups, as
|
||||
there is only 1 CPU available outside (on a 4 CPU cluster). The 2 CPUs
|
||||
thus have to come from the placement group.
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
manager.request_resources(REQUEST_1_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_1_2_CPU)
|
||||
|
||||
@ray.remote
|
||||
def needs_cpus():
|
||||
return "Ok"
|
||||
|
||||
@ray.remote
|
||||
def spawn_child_task(num_cpus: int):
|
||||
return ray.get(needs_cpus.options(num_cpus=num_cpus).remote())
|
||||
|
||||
acq = manager.acquire_resources(REQUEST_1_2_CPU)
|
||||
[av1] = acq.annotate_remote_entities([spawn_child_task])
|
||||
|
||||
res = ray.get(av1.remote(2), timeout=2.0)
|
||||
|
||||
assert res
|
||||
|
||||
|
||||
def test_clear_state(ray_start_4_cpus):
|
||||
"""Test that clearing state will remove existing placement groups.
|
||||
|
||||
- Create resource request
|
||||
- Wait until PG is scheduled
|
||||
- Assert that Ray PG is created
|
||||
- Call `mgr.clear()`
|
||||
- Assert that resources are not ready anymore
|
||||
- Assert that Ray PG is removed
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
manager.request_resources(REQUEST_1_2_CPU)
|
||||
ray.wait(manager.get_resource_futures(), num_returns=1)
|
||||
|
||||
assert manager.has_resources_ready(REQUEST_1_2_CPU)
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 1
|
||||
assert pg_states["PENDING"] == 0
|
||||
assert pg_states["REMOVED"] == 0
|
||||
|
||||
manager.clear()
|
||||
|
||||
assert not manager.has_resources_ready(REQUEST_1_2_CPU)
|
||||
|
||||
pg_states = _count_pg_states()
|
||||
assert pg_states["CREATED"] == 0
|
||||
assert pg_states["PENDING"] == 0
|
||||
assert pg_states["REMOVED"] == 1
|
||||
|
||||
|
||||
def test_internal_state(ray_start_4_cpus):
|
||||
"""Test internal state mappings of the placement group manager.
|
||||
|
||||
This test makes assumptions and assertions around the internal state transition
|
||||
of private properties of the placement group resource manager.
|
||||
|
||||
If you change internal handling logic of the manager, you may need to change this
|
||||
test as well.
|
||||
"""
|
||||
manager = PlacementGroupResourceManager(update_interval_s=0)
|
||||
|
||||
assert manager.update_interval_s == 0
|
||||
|
||||
manager.has_resources_ready(REQUEST_2_CPU)
|
||||
|
||||
# The key may exist but the set should be empty
|
||||
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
|
||||
|
||||
####
|
||||
# 1. Request, wait until ready, cancel
|
||||
|
||||
# Request resources
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
|
||||
# PG should be staged
|
||||
assert manager._request_to_staged_pgs[REQUEST_2_CPU]
|
||||
pg = list(manager._request_to_staged_pgs[REQUEST_2_CPU])[0]
|
||||
assert manager._pg_to_request[pg] == REQUEST_2_CPU
|
||||
|
||||
# Staging future should exist
|
||||
assert manager._pg_to_staging_future[pg]
|
||||
fut = manager._pg_to_staging_future[pg]
|
||||
assert manager._staging_future_to_pg[fut] == pg
|
||||
|
||||
# Wait until PG is ready
|
||||
while not manager.has_resources_ready(resource_request=REQUEST_2_CPU):
|
||||
time.sleep(0.05)
|
||||
|
||||
# PG should now be ready
|
||||
assert manager._request_to_ready_pgs[REQUEST_2_CPU]
|
||||
# PG should not be staged anymore
|
||||
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
|
||||
# Staging future should not exist anymore
|
||||
assert not manager._pg_to_staging_future
|
||||
assert not manager._staging_future_to_pg
|
||||
|
||||
# Cancel request
|
||||
manager.cancel_resource_request(REQUEST_2_CPU)
|
||||
|
||||
# PG should not be ready anymore
|
||||
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
|
||||
# All PGs should be fully removed
|
||||
assert not manager._pg_to_request
|
||||
|
||||
####
|
||||
# 2. Request, cancel while staging
|
||||
|
||||
# Stage another PG
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
# Cancel request before it's ready
|
||||
manager.cancel_resource_request(REQUEST_2_CPU)
|
||||
# Assert no leftover
|
||||
assert not manager._pg_to_staging_future
|
||||
assert not manager._staging_future_to_pg
|
||||
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
|
||||
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
|
||||
assert not manager._pg_to_request
|
||||
|
||||
####
|
||||
# 2. Request, acquire, free
|
||||
|
||||
# Stage another PG
|
||||
manager.request_resources(REQUEST_2_CPU)
|
||||
pg = list(manager._request_to_staged_pgs[REQUEST_2_CPU])[0]
|
||||
# Wait until PG is ready
|
||||
while not manager.has_resources_ready(resource_request=REQUEST_2_CPU):
|
||||
time.sleep(0.05)
|
||||
# Acquire
|
||||
acquired_resources = manager.acquire_resources(resource_request=REQUEST_2_CPU)
|
||||
# Assert no staging/ready leftover
|
||||
assert not manager._pg_to_staging_future
|
||||
assert not manager._staging_future_to_pg
|
||||
assert not manager._request_to_staged_pgs[REQUEST_2_CPU]
|
||||
assert not manager._request_to_ready_pgs[REQUEST_2_CPU]
|
||||
# We still retain this mapping
|
||||
assert manager._pg_to_request
|
||||
# And we keep track of acquired PGs
|
||||
assert pg in manager._acquired_pgs
|
||||
|
||||
# Free PG
|
||||
manager.free_resources(acquired_resources)
|
||||
# State should be cleared now
|
||||
assert not manager._pg_to_request
|
||||
assert not manager._acquired_pgs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
|
||||
from ray.air.execution.resources.request import ResourceRequest
|
||||
|
||||
|
||||
def test_request_same():
|
||||
"""Test that resource requests are the same if they share the same properties."""
|
||||
|
||||
assert ResourceRequest([{"CPU": 1}]) == ResourceRequest([{"CPU": 1}])
|
||||
|
||||
# multiple bundles work
|
||||
assert ResourceRequest([{"CPU": 1}, {"CPU": 2}]) == ResourceRequest(
|
||||
[{"CPU": 1}, {"CPU": 2}]
|
||||
)
|
||||
|
||||
# multiple resources work
|
||||
assert ResourceRequest([{"CPU": 1, "GPU": 1}]) == ResourceRequest(
|
||||
[{"CPU": 1, "GPU": 1}]
|
||||
)
|
||||
|
||||
# 0 resources are ignored
|
||||
assert ResourceRequest([{"CPU": 0, "GPU": 1}]) == ResourceRequest([{"GPU": 1}])
|
||||
|
||||
# PACK is implicit
|
||||
assert ResourceRequest([{"CPU": 1}], strategy="PACK") == ResourceRequest(
|
||||
[{"CPU": 1}]
|
||||
)
|
||||
|
||||
# Non match: different strategy
|
||||
assert ResourceRequest([{"CPU": 1}], strategy="PACK") != ResourceRequest(
|
||||
[{"CPU": 1}], strategy="SPREAD"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,366 @@
|
||||
import gc
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air import ResourceRequest
|
||||
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
|
||||
from ray.air.execution._internal import Barrier
|
||||
from ray.air.execution._internal.actor_manager import RayActorManager
|
||||
|
||||
|
||||
def _raise(exception_type: Type[Exception] = RuntimeError, msg: Optional[str] = None):
|
||||
def _raise_exception(*args, **kwargs):
|
||||
raise exception_type(msg)
|
||||
|
||||
return _raise_exception
|
||||
|
||||
|
||||
class Started(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Stopped(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Failed(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Result(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cleanup():
|
||||
# Garbage collect at the start
|
||||
# This ensures that all resources are freed up for the upcoming test.
|
||||
gc.collect()
|
||||
yield
|
||||
|
||||
|
||||
class Actor:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def get_kwargs(self):
|
||||
return self.kwargs
|
||||
|
||||
def task(self, value: Any):
|
||||
return value
|
||||
|
||||
|
||||
@ray.remote(num_cpus=4)
|
||||
def fn():
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
@pytest.mark.parametrize("actor_cls", [Actor, ray.remote(Actor)])
|
||||
@pytest.mark.parametrize("kill", [False, True])
|
||||
def test_start_stop_actor(ray_start_4_cpus, resource_manager_cls, actor_cls, kill):
|
||||
"""Test that starting and stopping actors work and invokes a callback.
|
||||
|
||||
- Start an actor
|
||||
- Starting should trigger start callback
|
||||
- Schedule actor task, which should resolve (meaning actor successfully started)
|
||||
- Stop actor, which should resolve and trigger stop callback
|
||||
- Schedule remote fn that takes up all cluster resources. This should resolve,
|
||||
meaning that the actor was stopped successfully.
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
# Start actor, set callbacks
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=actor_cls,
|
||||
kwargs={"key": "val"},
|
||||
resource_request=ResourceRequest([{"CPU": 4}]),
|
||||
on_start=_raise(Started),
|
||||
on_stop=_raise(Stopped),
|
||||
on_error=_raise(Failed),
|
||||
)
|
||||
|
||||
# Actor should be started
|
||||
with pytest.raises(Started):
|
||||
actor_manager.next()
|
||||
|
||||
# Schedule task on actor which should resolve (actor successfully started)
|
||||
actor_manager.schedule_actor_task(
|
||||
tracked_actor, "task", (1,), on_result=_raise(Result)
|
||||
)
|
||||
|
||||
with pytest.raises(Result):
|
||||
actor_manager.next()
|
||||
|
||||
# Now we can assert that there are no CPUS resources available anymore.
|
||||
# Note that actor starting is asynchronous, so we can't assert this right away
|
||||
# - that's why we wait for the actor task to resolve first.
|
||||
assert ray.available_resources().get("CPU", 0.0) == 0, ray.available_resources()
|
||||
|
||||
# Stop actor
|
||||
actor_manager.remove_actor(tracked_actor, kill=kill)
|
||||
|
||||
with pytest.raises(Stopped):
|
||||
actor_manager.next()
|
||||
|
||||
# This task takes up all the cluster resources. It should resolve now that
|
||||
# the actor was terminated.
|
||||
assert ray.get(fn.remote(), timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
def test_start_many_actors(ray_start_4_cpus, resource_manager_cls):
|
||||
"""Test that starting more actors than fit onto the cluster works.
|
||||
|
||||
- Request 10 actors
|
||||
- 4 can be started. Assert they are started
|
||||
- Stop 2
|
||||
- Assert 2 are stopped and 2 new ones are started
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
running_actors = []
|
||||
# stats keeps track of started/stopped actors
|
||||
stats = Counter()
|
||||
|
||||
def start_callback(tracked_actor):
|
||||
running_actors.append(tracked_actor)
|
||||
stats["started"] += 1
|
||||
|
||||
def stop_callback(tracked_actor):
|
||||
running_actors.remove(tracked_actor)
|
||||
stats["stopped"] += 1
|
||||
|
||||
# start 10 actors
|
||||
expected_actors = []
|
||||
for i in range(10):
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=Actor,
|
||||
kwargs={"key": "val"},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=start_callback,
|
||||
on_stop=stop_callback,
|
||||
on_error=_raise(Failed),
|
||||
)
|
||||
expected_actors.append(tracked_actor)
|
||||
|
||||
# wait for some actor starts
|
||||
for i in range(4):
|
||||
actor_manager.next()
|
||||
|
||||
# we should now have 4 started actors
|
||||
assert stats["started"] == 4
|
||||
assert stats["stopped"] == 0
|
||||
assert len(running_actors) == 4
|
||||
assert set(running_actors) == set(expected_actors[:4])
|
||||
|
||||
# stop 2 actors
|
||||
actor_manager.remove_actor(running_actors[0])
|
||||
actor_manager.remove_actor(running_actors[1])
|
||||
|
||||
# Wait four times, twice for termination, twice for start
|
||||
for i in range(4):
|
||||
actor_manager.next()
|
||||
|
||||
# we should have 4 running actors, 6 started and 2 stopped
|
||||
assert stats["started"] == 6
|
||||
assert stats["stopped"] == 2
|
||||
assert len(running_actors) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
@pytest.mark.parametrize("where", ["init", "fn"])
|
||||
def test_actor_fail(ray_start_4_cpus, cleanup, resource_manager_cls, where):
|
||||
"""Test that actor failures are handled properly.
|
||||
|
||||
- Start actor that either fails on init or in a task (RayActorError)
|
||||
- Schedule task on actor
|
||||
- Assert that the correct callbacks are called
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
# keep track of failed tasks and actors
|
||||
stats = Counter()
|
||||
|
||||
@ray.remote
|
||||
class FailingActor:
|
||||
def __init__(self, where):
|
||||
self._where = where
|
||||
if self._where == "init":
|
||||
raise RuntimeError("INIT")
|
||||
|
||||
def fn(self):
|
||||
if self._where == "fn":
|
||||
# SystemExit will invoke a RayActorError
|
||||
raise SystemExit
|
||||
return True
|
||||
|
||||
def fail_callback_actor(tracked_actor, exception):
|
||||
stats["failed_actor"] += 1
|
||||
|
||||
def fail_callback_task(tracked_actor, exception):
|
||||
stats["failed_task"] += 1
|
||||
|
||||
# Start actor
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=FailingActor,
|
||||
kwargs={"where": where},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_error=fail_callback_actor,
|
||||
)
|
||||
|
||||
if where != "init":
|
||||
# Wait until it is started. This won't invoke any callback, yet
|
||||
actor_manager.next()
|
||||
|
||||
assert stats["failed_actor"] == 0
|
||||
assert stats["failed_task"] == 0
|
||||
|
||||
# Schedule task
|
||||
actor_manager.schedule_actor_task(
|
||||
tracked_actor, "fn", on_error=fail_callback_task
|
||||
)
|
||||
|
||||
# Yield control and wait for task resolution. This will invoke the callback.
|
||||
actor_manager.next()
|
||||
|
||||
assert stats["failed_actor"] == 1
|
||||
assert stats["failed_task"] == bool(where != "init")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
def test_stop_actor_before_start(
|
||||
ray_start_4_cpus, tmp_path, cleanup, resource_manager_cls
|
||||
):
|
||||
"""Test that actor failures are handled properly.
|
||||
|
||||
- Start actor that either fails on init or in a task (RayActorError)
|
||||
- Schedule task on actor
|
||||
- Assert that the correct callbacks are called
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
hang_marker = tmp_path / "hang.txt"
|
||||
|
||||
@ray.remote
|
||||
class HangingActor:
|
||||
def __init__(self):
|
||||
while not hang_marker.exists():
|
||||
time.sleep(0.05)
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
HangingActor,
|
||||
kwargs={},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=_raise(RuntimeError, "Should not have started"),
|
||||
on_stop=_raise(RuntimeError, "Should not have stopped"),
|
||||
)
|
||||
while not actor_manager.is_actor_started(tracked_actor):
|
||||
actor_manager.next(0.05)
|
||||
|
||||
# Actor started but hasn't triggered on_start, yet
|
||||
actor_manager.remove_actor(tracked_actor)
|
||||
hang_marker.write_text("")
|
||||
while actor_manager.is_actor_started(tracked_actor):
|
||||
actor_manager.next(0.05)
|
||||
|
||||
assert actor_manager.num_live_actors == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource_manager_cls", [FixedResourceManager, PlacementGroupResourceManager]
|
||||
)
|
||||
@pytest.mark.parametrize("start_thread", [False, True])
|
||||
def test_stop_actor_custom_future(
|
||||
ray_start_4_cpus, tmp_path, cleanup, resource_manager_cls, start_thread
|
||||
):
|
||||
"""If we pass a custom stop future, the actor should still be shutdown by GC.
|
||||
|
||||
This should also be the case when we start a thread in the background, as we
|
||||
do e.g. in Ray Tune's function runner.
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
hang_marker = tmp_path / "hang.txt"
|
||||
|
||||
actor_name = f"stopping_actor_{resource_manager_cls.__name__}_{start_thread}"
|
||||
|
||||
@ray.remote(name=actor_name)
|
||||
class HangingStopActor:
|
||||
def __init__(self):
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
if start_thread:
|
||||
|
||||
def entrypoint():
|
||||
while True:
|
||||
print("Thread!")
|
||||
time.sleep(1)
|
||||
if self._stop_event.is_set():
|
||||
sys.exit(0)
|
||||
|
||||
self._thread = threading.Thread(target=entrypoint)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
print("Waiting")
|
||||
while not hang_marker.exists():
|
||||
time.sleep(0.05)
|
||||
self._stop_event.set()
|
||||
print("stopped")
|
||||
|
||||
start_barrier = Barrier(max_results=1)
|
||||
stop_barrier = Barrier(max_results=1)
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
HangingStopActor,
|
||||
kwargs={},
|
||||
resource_request=ResourceRequest([{"CPU": 1}]),
|
||||
on_start=start_barrier.arrive,
|
||||
on_stop=stop_barrier.arrive,
|
||||
)
|
||||
while not start_barrier.completed:
|
||||
actor_manager.next(0.05)
|
||||
|
||||
# Actor is alive
|
||||
assert ray.get_actor(actor_name)
|
||||
|
||||
stop_future = actor_manager.schedule_actor_task(tracked_actor, "stop")
|
||||
actor_manager.remove_actor(tracked_actor, kill=False, stop_future=stop_future)
|
||||
|
||||
assert not stop_barrier.completed
|
||||
|
||||
hang_marker.write_text("!")
|
||||
|
||||
while not stop_barrier.completed:
|
||||
actor_manager.next(0.05)
|
||||
|
||||
# Actor should have stopped now and should get cleaned up
|
||||
with pytest.raises(ValueError):
|
||||
ray.get_actor(actor_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,122 @@
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air import ResourceRequest
|
||||
from ray.air.execution import FixedResourceManager, PlacementGroupResourceManager
|
||||
from ray.air.execution._internal.actor_manager import RayActorManager
|
||||
|
||||
RESOURCE_MANAGERS = [FixedResourceManager, PlacementGroupResourceManager]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@ray.remote
|
||||
class Actor:
|
||||
def foo(self, val, error: bool = False):
|
||||
if error:
|
||||
raise RuntimeError
|
||||
return val
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
|
||||
def test_resolve(ray_start_4_cpus, resource_manager_cls):
|
||||
"""Test that the `on_result` callback is invoked when a task completes.
|
||||
|
||||
- Instantiate global data object
|
||||
- Schedule task that returns a value
|
||||
- The callback writes the returned value to the global data object
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
seen = {"data": 0}
|
||||
|
||||
def result_callback(tracked_actor, result):
|
||||
seen["data"] = result
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
|
||||
)
|
||||
actor_manager.schedule_actor_task(
|
||||
tracked_actor, "foo", (4, False), on_result=result_callback
|
||||
)
|
||||
actor_manager.next()
|
||||
actor_manager.next()
|
||||
|
||||
assert seen["data"] == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
|
||||
@pytest.mark.parametrize("num_tasks", [1, 10, 100])
|
||||
def test_resolve_many(ray_start_4_cpus, resource_manager_cls, num_tasks):
|
||||
"""Schedule ``num_tasks`` tasks and wait until ``wait_for_events`` of them resolve.
|
||||
|
||||
Every resolved task will increase a counter by its return value (1).
|
||||
"""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
seen = {"data": 0}
|
||||
|
||||
def result_callback(tracked_actor, result):
|
||||
seen["data"] += result
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
|
||||
)
|
||||
actor_manager.next()
|
||||
|
||||
for i in range(num_tasks):
|
||||
actor_manager.schedule_actor_task(
|
||||
tracked_actor, "foo", (1, False), on_result=result_callback
|
||||
)
|
||||
|
||||
for i in range(num_tasks):
|
||||
actor_manager.next()
|
||||
assert seen["data"] == i + 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
|
||||
def test_error_noop(ray_start_4_cpus, resource_manager_cls):
|
||||
"""When no `on_error` callback is specified, errors should be ignored."""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
|
||||
)
|
||||
actor_manager.schedule_actor_task(tracked_actor, "foo", (1, True))
|
||||
actor_manager.next()
|
||||
actor_manager.next()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resource_manager_cls", RESOURCE_MANAGERS)
|
||||
def test_error_custom(ray_start_4_cpus, resource_manager_cls):
|
||||
"""When an `on_error` callback is specified, it is invoked."""
|
||||
actor_manager = RayActorManager(resource_manager=resource_manager_cls())
|
||||
|
||||
stats = Counter()
|
||||
|
||||
def error_callback(tracked_actor, exception):
|
||||
stats["exception"] += 1
|
||||
|
||||
tracked_actor = actor_manager.add_actor(
|
||||
cls=Actor, kwargs={}, resource_request=ResourceRequest([{"CPU": 4}])
|
||||
)
|
||||
actor_manager.schedule_actor_task(
|
||||
tracked_actor, "foo", (1, True), on_error=error_callback
|
||||
)
|
||||
|
||||
actor_manager.next()
|
||||
actor_manager.next()
|
||||
assert stats["exception"] == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,136 @@
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
from unittest.mock import Mock
|
||||
|
||||
from wandb.util import json_dumps_safer
|
||||
|
||||
import ray
|
||||
from ray.air.integrations.wandb import WandbLoggerCallback, _WandbLoggingActor
|
||||
|
||||
|
||||
class Trial(
|
||||
namedtuple(
|
||||
"MockTrial",
|
||||
[
|
||||
"config",
|
||||
"trial_id",
|
||||
"trial_name",
|
||||
"experiment_dir_name",
|
||||
"placement_group_factory",
|
||||
"local_path",
|
||||
],
|
||||
)
|
||||
):
|
||||
def __hash__(self):
|
||||
return hash(self.trial_id)
|
||||
|
||||
def __str__(self):
|
||||
return self.trial_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingActorState:
|
||||
args: list
|
||||
kwargs: dict
|
||||
exclude: list
|
||||
logs: list
|
||||
config: dict
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
def __init__(self):
|
||||
self.config = {}
|
||||
|
||||
def update(self, config, *args, **kwargs):
|
||||
self.config.update(config)
|
||||
|
||||
|
||||
class _MockWandbAPI:
|
||||
"""Thread-safe.
|
||||
|
||||
Note: Not implemented to mock re-init behavior properly. Proceed with caution."""
|
||||
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
self.config = _FakeConfig()
|
||||
|
||||
def init(self, *args, **kwargs):
|
||||
mock = Mock()
|
||||
mock.args = args
|
||||
mock.kwargs = kwargs
|
||||
|
||||
if "config" in kwargs:
|
||||
self.config.update(kwargs["config"])
|
||||
|
||||
return mock
|
||||
|
||||
def log(self, data, step=None):
|
||||
try:
|
||||
json_dumps_safer(data)
|
||||
except Exception:
|
||||
self.logs.append("serialization error")
|
||||
else:
|
||||
self.logs.append(data)
|
||||
|
||||
def finish(self):
|
||||
pass
|
||||
|
||||
def get_logs(self):
|
||||
return self.logs
|
||||
|
||||
def get_config(self):
|
||||
return self.config.config
|
||||
|
||||
|
||||
class _MockWandbLoggingActor(_WandbLoggingActor):
|
||||
_mock_wandb_api_cls = _MockWandbAPI
|
||||
|
||||
def __init__(self, logdir, queue, exclude, to_config, *args, **kwargs):
|
||||
super(_MockWandbLoggingActor, self).__init__(
|
||||
logdir, queue, exclude, to_config, *args, **kwargs
|
||||
)
|
||||
self._wandb = self._mock_wandb_api_cls()
|
||||
|
||||
def get_state(self):
|
||||
return LoggingActorState(
|
||||
args=self.args,
|
||||
kwargs=self.kwargs,
|
||||
exclude=self._exclude,
|
||||
logs=self._wandb.get_logs(),
|
||||
config=self._wandb.get_config(),
|
||||
)
|
||||
|
||||
|
||||
class WandbTestExperimentLogger(WandbLoggerCallback):
|
||||
"""Wandb logger with mocked Wandb API gateway (one per trial)."""
|
||||
|
||||
_logger_actor_cls = _MockWandbLoggingActor
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_actor_states: Dict["Trial", LoggingActorState] = {}
|
||||
|
||||
def _cleanup_logging_actor(self, trial: "Trial", **kwargs):
|
||||
logging_actor_state: LoggingActorState = ray.get(
|
||||
self._trial_logging_actors[trial].get_state.remote()
|
||||
)
|
||||
self._saved_actor_states[trial] = logging_actor_state
|
||||
super()._cleanup_logging_actor(trial, **kwargs)
|
||||
|
||||
@property
|
||||
def trial_logging_actor_states(self) -> Dict["Trial", LoggingActorState]:
|
||||
return self._saved_actor_states
|
||||
|
||||
|
||||
def get_mock_wandb_logger(mock_api_cls=_MockWandbAPI, **kwargs):
|
||||
class MockWandbLoggingActor(_MockWandbLoggingActor):
|
||||
_mock_wandb_api_cls = mock_api_cls
|
||||
|
||||
logger = WandbTestExperimentLogger(
|
||||
project="test_project",
|
||||
api_key="1234",
|
||||
**kwargs,
|
||||
)
|
||||
logger._logger_actor_cls = MockWandbLoggingActor
|
||||
return logger
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Unit tests for AIR telemetry."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyarrow.fs
|
||||
import pytest
|
||||
from packaging.version import Version
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray._common.usage.usage_lib import TagKey
|
||||
from ray.air._internal import usage as air_usage
|
||||
from ray.air._internal.usage import AirEntrypoint
|
||||
from ray.air.integrations import comet, mlflow, wandb
|
||||
from ray.train._internal.storage import StorageContext
|
||||
from ray.tune.callback import Callback
|
||||
from ray.tune.experiment.experiment import Experiment
|
||||
from ray.tune.logger import LoggerCallback
|
||||
from ray.tune.utils.callback import DEFAULT_CALLBACK_CLASSES
|
||||
|
||||
|
||||
def _mock_record_from_module(module, monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def mock_record_extra_usage_tag(key: TagKey, value: str):
|
||||
recorded[key] = value
|
||||
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"record_extra_usage_tag",
|
||||
mock_record_extra_usage_tag,
|
||||
)
|
||||
return recorded
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_record(monkeypatch):
|
||||
import ray.air._internal.usage
|
||||
|
||||
yield _mock_record_from_module(ray.air._internal.usage, monkeypatch=monkeypatch)
|
||||
|
||||
|
||||
def train_fn(config):
|
||||
train.report({"score": 1})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tuner(tmp_path):
|
||||
yield tune.Tuner(train_fn, run_config=tune.RunConfig(storage_path=str(tmp_path)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trainer(tmp_path):
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
yield DataParallelTrainer(
|
||||
train_loop_per_worker=train_fn,
|
||||
scaling_config=train.ScalingConfig(num_workers=2),
|
||||
run_config=train.RunConfig(storage_path=str(tmp_path)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"storage_path_filesystem_expected",
|
||||
[
|
||||
("/tmp/test", None, "local"),
|
||||
("s3://", None, "s3"),
|
||||
("gs://test", None, "gcs"),
|
||||
("mock://test", None, "mock"),
|
||||
("test", pyarrow.fs.LocalFileSystem(), "custom"),
|
||||
],
|
||||
)
|
||||
def test_tag_storage_type(storage_path_filesystem_expected, mock_record, monkeypatch):
|
||||
# Don't write anything to storage for the test.
|
||||
monkeypatch.setattr(StorageContext, "_create_validation_file", lambda _: None)
|
||||
monkeypatch.setattr(StorageContext, "_check_validation_file", lambda _: None)
|
||||
|
||||
storage_path, storage_filesystem, expected = storage_path_filesystem_expected
|
||||
|
||||
if Version(pyarrow.__version__) < Version("17.0.0") and storage_path.startswith(
|
||||
"gs://"
|
||||
):
|
||||
pytest.skip("GCS support requires pyarrow >= 17.0.0")
|
||||
|
||||
storage = StorageContext(
|
||||
storage_path=storage_path,
|
||||
experiment_dir_name="test",
|
||||
storage_filesystem=storage_filesystem,
|
||||
)
|
||||
air_usage.tag_storage_type(storage)
|
||||
assert mock_record[TagKey.AIR_STORAGE_CONFIGURATION] == expected
|
||||
|
||||
|
||||
class _CustomLoggerCallback(LoggerCallback):
|
||||
pass
|
||||
|
||||
|
||||
class _CustomCallback(Callback):
|
||||
pass
|
||||
|
||||
|
||||
_TEST_CALLBACKS = [
|
||||
wandb.WandbLoggerCallback,
|
||||
mlflow.MLflowLoggerCallback,
|
||||
comet.CometLoggerCallback,
|
||||
_CustomLoggerCallback,
|
||||
_CustomLoggerCallback,
|
||||
_CustomCallback,
|
||||
]
|
||||
|
||||
|
||||
def test_tag_setup_wandb(mock_record):
|
||||
from ray.air.integrations.wandb import _setup_wandb
|
||||
|
||||
with patch.dict(os.environ, {wandb.WANDB_MODE_ENV_VAR: "disabled"}):
|
||||
_setup_wandb(trial_id="a", trial_name="b", config={}, _wandb=MagicMock())
|
||||
assert mock_record[TagKey.AIR_SETUP_WANDB_INTEGRATION_USED] == "1"
|
||||
|
||||
|
||||
def test_tag_setup_mlflow(mock_record, monkeypatch):
|
||||
from ray.air.integrations.mlflow import setup_mlflow
|
||||
|
||||
monkeypatch.setattr(ray.air.integrations.mlflow, "_MLflowLoggerUtil", MagicMock())
|
||||
setup_mlflow()
|
||||
assert mock_record[TagKey.AIR_SETUP_MLFLOW_INTEGRATION_USED] == "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"callback_classes_expected",
|
||||
[
|
||||
(None, None),
|
||||
([], None),
|
||||
([lambda: None], None),
|
||||
(
|
||||
DEFAULT_CALLBACK_CLASSES,
|
||||
{cls.__name__: 1 for cls in DEFAULT_CALLBACK_CLASSES},
|
||||
),
|
||||
(
|
||||
_TEST_CALLBACKS,
|
||||
{
|
||||
"WandbLoggerCallback": 1,
|
||||
"MLflowLoggerCallback": 1,
|
||||
"CometLoggerCallback": 1,
|
||||
"CustomLoggerCallback": 2,
|
||||
"CustomCallback": 1,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_tag_callbacks(mock_record, callback_classes_expected):
|
||||
callback_classes, expected = callback_classes_expected
|
||||
|
||||
callbacks = (
|
||||
[callback_cls() for callback_cls in callback_classes]
|
||||
if callback_classes
|
||||
else None
|
||||
)
|
||||
|
||||
air_usage.tag_callbacks(callbacks)
|
||||
|
||||
callback_usage_str = mock_record.pop(TagKey.AIR_CALLBACKS, None)
|
||||
callback_counts = json.loads(callback_usage_str) if callback_usage_str else None
|
||||
assert callback_counts == expected
|
||||
|
||||
|
||||
def test_tag_env_vars(ray_start_4_cpus, mock_record, tuner):
|
||||
"""Test that env vars are recorded properly, and arbitrary user environment
|
||||
variables are ignored."""
|
||||
env_vars_to_record = {
|
||||
"TUNE_GLOBAL_CHECKPOINT_S": "20",
|
||||
"TUNE_MAX_PENDING_TRIALS_PG": "1",
|
||||
}
|
||||
untracked_env_vars = {"RANDOM_USER_ENV_VAR": "asdf"}
|
||||
|
||||
with patch.dict(os.environ, {**env_vars_to_record, **untracked_env_vars}):
|
||||
tuner.fit()
|
||||
|
||||
recorded_env_vars = json.loads(mock_record[TagKey.AIR_ENV_VARS])
|
||||
assert sorted(env_vars_to_record) == sorted(recorded_env_vars)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", list(AirEntrypoint))
|
||||
def test_tag_air_entrypoint(ray_start_4_cpus, mock_record, entrypoint, tuner, trainer):
|
||||
if entrypoint == AirEntrypoint.TUNE_RUN:
|
||||
tune.run(train_fn)
|
||||
elif entrypoint == AirEntrypoint.TUNE_RUN_EXPERIMENTS:
|
||||
experiment_spec = Experiment("experiment", train_fn)
|
||||
tune.run_experiments(experiments=experiment_spec)
|
||||
elif entrypoint == AirEntrypoint.TUNER:
|
||||
tuner.fit()
|
||||
elif entrypoint == AirEntrypoint.TRAINER:
|
||||
trainer.fit()
|
||||
|
||||
assert mock_record[TagKey.AIR_ENTRYPOINT] == entrypoint.value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
This test suite covers error handling and propagation in Ray Train/Tune.
|
||||
|
||||
There are two main error types to test:
|
||||
1. Trainable errors: These happen in the remote actor itself.
|
||||
-> Within this, we should test:
|
||||
- fail_fast=True/False/'raise'
|
||||
- AIR Trainer w/o Tuner, AIR Trainer w/ Tuner, Tuner w/ function trainable
|
||||
2. Tune driver errors: These happen in the Tune event-handling loop.
|
||||
-> Within this, we should test:
|
||||
- Errors occurring at different points in the Tune loop
|
||||
(on_trial_result, on_checkpoint, on_step_begin, etc.)
|
||||
|
||||
These tests should:
|
||||
- Assert how errors from the trainable/Trainer get propagated to the user.
|
||||
- Assert how errors from the Tune driver get propagated to the user.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import threading
|
||||
import time
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train, tune
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
from ray._raylet import GcsClient
|
||||
from ray.cluster_utils import Cluster
|
||||
from ray.core.generated import autoscaler_pb2
|
||||
from ray.tests.conftest import * # noqa
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
from ray.train.tests.util import create_dict_checkpoint, load_dict_checkpoint
|
||||
from ray.train.trainer import BaseTrainer, TrainingFailedError
|
||||
from ray.tune import TuneError, Tuner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4, configure_logging=False)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def gc_collect():
|
||||
# Make sure to cleanup as much as possible between
|
||||
# unit tests that share a Ray session
|
||||
yield
|
||||
gc.collect()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cluster_setup(ray_start_cluster_head: Cluster):
|
||||
# Sets up a cluster with 3 nodes: head node + 2 workers
|
||||
cluster = ray_start_cluster_head
|
||||
nodes = []
|
||||
nodes.append(cluster.add_node(resources={"worker1": 1, "cpu": 1, "coordinator": 1}))
|
||||
nodes.append(cluster.add_node(resources={"worker2": 1, "cpu": 1}))
|
||||
cluster.wait_for_nodes()
|
||||
|
||||
@ray.remote
|
||||
def get_node_id():
|
||||
return ray.get_runtime_context().get_node_id()
|
||||
|
||||
worker1_node_id = ray.get(get_node_id.options(resources={"worker1": 1}).remote())
|
||||
worker2_node_id = ray.get(get_node_id.options(resources={"worker2": 1}).remote())
|
||||
wait_for_condition(
|
||||
lambda: len({node["NodeID"] for node in ray.nodes() if (node["Alive"])}) == 3
|
||||
)
|
||||
|
||||
yield cluster, nodes, [
|
||||
worker1_node_id,
|
||||
worker2_node_id,
|
||||
]
|
||||
|
||||
|
||||
class _TestSpecificError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FailingCallback(tune.Callback):
|
||||
def __init__(self, error_on: str):
|
||||
self.error_on = error_on
|
||||
|
||||
def on_trial_result(self, *args, **kwargs):
|
||||
if self.error_on == "on_trial_result":
|
||||
raise _TestSpecificError(f"Failing on {self.error_on}!")
|
||||
|
||||
|
||||
class FailingTrainer(BaseTrainer):
|
||||
def training_loop(self) -> None:
|
||||
raise _TestSpecificError("There is an error in trainer!")
|
||||
|
||||
|
||||
def passing_fn(config):
|
||||
# Trigger all the driver events (on_checkpoint, on_trial_save, etc.)
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
train.report({"score": 1}, checkpoint=train.Checkpoint.from_directory(tmpdir))
|
||||
|
||||
|
||||
def failing_fn(config):
|
||||
raise _TestSpecificError("Failing!")
|
||||
|
||||
|
||||
trainable_map = {
|
||||
"function": failing_fn,
|
||||
"trainer": FailingTrainer(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_fast", [False, True, "raise"])
|
||||
def test_trainable_error_with_tuner(ray_start_4_cpus, fail_fast):
|
||||
tuner = Tuner(
|
||||
trainable=failing_fn,
|
||||
run_config=tune.RunConfig(
|
||||
name=f"tuner_errors-fail_fast={fail_fast}",
|
||||
failure_config=tune.FailureConfig(fail_fast=fail_fast),
|
||||
),
|
||||
tune_config=tune.TuneConfig(num_samples=2),
|
||||
)
|
||||
|
||||
if fail_fast is False:
|
||||
# Both trials should complete with an error.
|
||||
results = tuner.fit()
|
||||
assert len(results) == 2
|
||||
for i in range(2):
|
||||
assert results[i].error
|
||||
elif fail_fast is True:
|
||||
# The first trial errors -> the experiment finishes immediately.
|
||||
results = tuner.fit()
|
||||
errors = [result.error for result in results if result.error]
|
||||
assert len(errors) == 1
|
||||
elif fail_fast == "raise":
|
||||
# The original error gets raised to the user
|
||||
with pytest.raises(_TestSpecificError):
|
||||
tuner.fit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_fast", [False, True, "raise"])
|
||||
def test_trainable_error_with_trainer(ray_start_4_cpus, tmp_path, fail_fast):
|
||||
name = f"test_trainer_errors-fail_fast={fail_fast}"
|
||||
trainer = FailingTrainer(
|
||||
run_config=train.RunConfig(
|
||||
storage_path=str(tmp_path),
|
||||
name=name,
|
||||
failure_config=train.FailureConfig(fail_fast=fail_fast),
|
||||
),
|
||||
scaling_config=train.ScalingConfig(num_workers=1),
|
||||
)
|
||||
|
||||
if fail_fast in [False, True]:
|
||||
# There is only 1 "trial" for a Trainer,
|
||||
# so fail_fast = True/False doesn't change the behavior
|
||||
# In both cases, the error should get wrapped and raised.
|
||||
with pytest.raises(TrainingFailedError) as exc_info:
|
||||
trainer.fit()
|
||||
|
||||
# The cause of the error should be the trainable error
|
||||
assert isinstance(exc_info.value.__cause__, _TestSpecificError)
|
||||
|
||||
assert TrainingFailedError._RESTORE_MSG.format(
|
||||
trainer_cls_name="FailingTrainer", path=str(tmp_path / name)
|
||||
) in str(exc_info.value)
|
||||
assert TrainingFailedError._FAILURE_CONFIG_MSG in str(exc_info.value)
|
||||
|
||||
elif fail_fast == "raise":
|
||||
# The original error gets raised to the user
|
||||
with pytest.raises(_TestSpecificError):
|
||||
trainer.fit()
|
||||
|
||||
|
||||
# TODO(ml-team): Test all the driver hooks once driver error propagation is fixed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_on", ["on_trial_result"])
|
||||
def test_driver_error_with_tuner(ray_start_4_cpus, error_on):
|
||||
tuner = Tuner(
|
||||
trainable=passing_fn,
|
||||
run_config=tune.RunConfig(
|
||||
name=f"test_driver_errors_with_tuner-error_on={error_on}",
|
||||
callbacks=[FailingCallback(error_on=error_on)],
|
||||
),
|
||||
)
|
||||
|
||||
# All driver errors should get propagated to the user in the same way
|
||||
with pytest.raises(TuneError) as exc_info:
|
||||
tuner.fit()
|
||||
|
||||
# TODO(ml-team): Assert the cause error type once driver error propagation is fixed
|
||||
assert "_TestSpecificError" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_at_level", ["worker", "coordinator"])
|
||||
def test_preemption_handling(
|
||||
cluster_setup,
|
||||
tmp_path,
|
||||
error_at_level: str,
|
||||
):
|
||||
"""Integration test for node preemption handling in Ray Train/Tune.
|
||||
Even though `max_failures=0`, preemption errors should still be retried."""
|
||||
cluster, nodes, node_ids = cluster_setup
|
||||
# node 1 = coordinator and worker, node 2 = worker
|
||||
coordinator_node, worker_node = nodes
|
||||
coordinator_node_id, worker_node_id = node_ids
|
||||
|
||||
num_workers = 2
|
||||
tmp_path.joinpath("markers").mkdir()
|
||||
|
||||
def train_fn(config):
|
||||
checkpoint = train.get_checkpoint()
|
||||
start_iter = 0
|
||||
if checkpoint:
|
||||
start_iter = load_dict_checkpoint(checkpoint)["iter"] + 1
|
||||
print(f"Restored at iter = {start_iter}")
|
||||
|
||||
for iter in range(start_iter, 6):
|
||||
with create_dict_checkpoint({"iter": iter}) as checkpoint:
|
||||
ray.train.report({"iter": iter}, checkpoint=checkpoint)
|
||||
|
||||
if iter == 2:
|
||||
# Write a "done marker" to tell the driver to simulate a preemption.
|
||||
tmp_path.joinpath(
|
||||
"markers", str(ray.train.get_context().get_world_rank())
|
||||
).touch()
|
||||
# Await execution.
|
||||
time.sleep(120)
|
||||
|
||||
def launch_training():
|
||||
trainer = DataParallelTrainer(
|
||||
train_loop_per_worker=train_fn,
|
||||
scaling_config=train.ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
trainer_resources={"coordinator": 1},
|
||||
resources_per_worker={"cpu": 1}, # worker2 and worker3
|
||||
),
|
||||
run_config=train.RunConfig(
|
||||
storage_path=str(tmp_path),
|
||||
name="test_preemption_error",
|
||||
failure_config=train.FailureConfig(fail_fast=False, max_failures=0),
|
||||
),
|
||||
)
|
||||
result = trainer.fit()
|
||||
assert result.metrics["iter"] == 5
|
||||
|
||||
t = threading.Thread(target=launch_training)
|
||||
t.start()
|
||||
|
||||
# Wait until the workers are ready for preemption (after a few checkpoints).
|
||||
while len(list(tmp_path.joinpath("markers").glob("*"))) < num_workers:
|
||||
time.sleep(0.5)
|
||||
|
||||
if error_at_level == "coordinator":
|
||||
node, node_id = coordinator_node, coordinator_node_id
|
||||
elif error_at_level == "worker":
|
||||
node, node_id = worker_node, worker_node_id
|
||||
else:
|
||||
raise NotImplementedError(f"Invalid error_at_level = {error_at_level}")
|
||||
|
||||
# Preempt a node.
|
||||
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
|
||||
print("Draining node...")
|
||||
is_accepted, _ = gcs_client.drain_node(
|
||||
node_id,
|
||||
autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"),
|
||||
"preemption",
|
||||
0,
|
||||
)
|
||||
assert is_accepted
|
||||
print("Killing node...")
|
||||
cluster.remove_node(node, allow_graceful=True)
|
||||
print("Adding new node..") # so that the job can be rescheduled
|
||||
# New node can replace a preempted coordinator or worker
|
||||
# NOTE: `cluster.add_node` only works in the main thread.
|
||||
cluster.add_node(resources={"coordinator": 1, "cpu": 1})
|
||||
t.join() # Assert no errors during training.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__] + sys.argv[1:]))
|
||||
@@ -0,0 +1,232 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ray.tune.analysis import ExperimentAnalysis
|
||||
from ray.tune.result_grid import ResultGrid
|
||||
|
||||
_RUN_SCRIPT_FILENAME = "_test_experiment_restore_run.py"
|
||||
|
||||
|
||||
def _kill_process_if_needed(
|
||||
process: subprocess.Popen, timeout_s: float = 10, poll_interval_s: float = 1.0
|
||||
):
|
||||
"""Kills a process if it hasn't finished in `timeout_s` seconds.
|
||||
Polls every `poll_interval_s` seconds to check if the process is still running."""
|
||||
kill_timeout = time.monotonic() + timeout_s
|
||||
while process.poll() is None and time.monotonic() < kill_timeout:
|
||||
time.sleep(poll_interval_s)
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
|
||||
|
||||
def _print_message(message):
|
||||
sep = "=" * 50
|
||||
print(f"\n{sep}\n{message}\n{sep}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runner_type", ["tuner", "trainer"])
|
||||
def test_experiment_restore(tmp_path, runner_type):
|
||||
"""
|
||||
This is an integration stress test for experiment restoration.
|
||||
|
||||
|
||||
Test setup:
|
||||
|
||||
- For Tuner.restore:
|
||||
- 8 trials, with a max of 2 running concurrently (--> 4 rounds of trials)
|
||||
- Each iteration takes 0.5 seconds
|
||||
- Each trial runs for 8 iterations --> 4 seconds
|
||||
- Each round of 2 trials should take 4 seconds
|
||||
- Without any interrupts/restoration:
|
||||
- Minimum runtime: 4 rounds * 4 seconds / round = 16 seconds
|
||||
- The test will stop the script with a SIGINT at a random time between
|
||||
6-10 iterations each restore.
|
||||
|
||||
- For Trainer.restore:
|
||||
- 1 trial with 4 workers
|
||||
- Each iteration takes 0.5 seconds
|
||||
- Runs for 32 iterations --> Minimum runtime = 16 seconds
|
||||
- The test will stop the script with a SIGINT at a random time between
|
||||
6-10 iterations after each restore.
|
||||
|
||||
Requirements:
|
||||
- Req 1: Training progress persisted
|
||||
- The experiment should progress monotonically.
|
||||
(The training iteration shouldn't go backward at any point)
|
||||
- Trials shouldn't start from scratch.
|
||||
- Req 2: Searcher state saved/restored correctly
|
||||
- Req 3: Callback state saved/restored correctly
|
||||
"""
|
||||
|
||||
np.random.seed(2023)
|
||||
|
||||
script_path = Path(__file__).parent / _RUN_SCRIPT_FILENAME
|
||||
|
||||
# Args to pass into the script as environment variables
|
||||
exp_name = f"{runner_type}_restore_integration_test"
|
||||
callback_dump_file = tmp_path / f"{runner_type}-callback_dump_file.json"
|
||||
storage_path = tmp_path / "ray_results"
|
||||
if storage_path.exists():
|
||||
shutil.rmtree(storage_path)
|
||||
|
||||
csv_file = str(tmp_path / "dummy_data.csv")
|
||||
dummy_df = pd.DataFrame({"x": np.arange(128), "y": 2 * np.arange(128)})
|
||||
dummy_df.to_csv(csv_file)
|
||||
|
||||
run_started_marker = tmp_path / "run_started_marker"
|
||||
|
||||
time_per_iter_s = 0.5
|
||||
max_concurrent = 2
|
||||
|
||||
if runner_type == "tuner":
|
||||
iters_per_trial = 8
|
||||
num_trials = 8
|
||||
elif runner_type == "trainer":
|
||||
iters_per_trial = 32
|
||||
num_trials = 1
|
||||
|
||||
total_iters = iters_per_trial * num_trials
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"RUNNER_TYPE": runner_type,
|
||||
"STORAGE_PATH": str(storage_path),
|
||||
"EXP_NAME": exp_name,
|
||||
"CALLBACK_DUMP_FILE": str(callback_dump_file),
|
||||
"RUN_STARTED_MARKER": str(run_started_marker),
|
||||
"TIME_PER_ITER_S": str(time_per_iter_s),
|
||||
"ITERATIONS_PER_TRIAL": str(iters_per_trial),
|
||||
"NUM_TRIALS": str(num_trials),
|
||||
"MAX_CONCURRENT_TRIALS": str(max_concurrent),
|
||||
"CSV_DATA_FILE": csv_file,
|
||||
}
|
||||
)
|
||||
|
||||
# Variables used in the loop
|
||||
return_code = None
|
||||
total_runtime = 0
|
||||
run_iter = 0
|
||||
progress = 0
|
||||
progress_history = []
|
||||
|
||||
poll_interval_s = 0.1
|
||||
test_start_time = time.monotonic()
|
||||
|
||||
while True:
|
||||
run_started_marker.write_text("", encoding="utf-8")
|
||||
|
||||
run = subprocess.Popen([sys.executable, script_path], env=env)
|
||||
run_iter += 1
|
||||
|
||||
_print_message(f"Started run #{run_iter} w/ PID = {run.pid}")
|
||||
|
||||
# Start the timer after the first trial has entered its training loop.
|
||||
while run.poll() is None and run_started_marker.exists():
|
||||
time.sleep(poll_interval_s)
|
||||
|
||||
# If the run already finished, then exit immediately.
|
||||
if run.poll() is not None:
|
||||
return_code = run.poll()
|
||||
break
|
||||
|
||||
timeout_s = np.random.uniform(6 * time_per_iter_s, 10 * time_per_iter_s)
|
||||
|
||||
_print_message(
|
||||
"Training has started...\n"
|
||||
f"Interrupting after {timeout_s:.2f} seconds\n"
|
||||
f"Currently at {total_runtime:.2f} seconds"
|
||||
)
|
||||
|
||||
# Sleep for a random amount of time, then stop the run.
|
||||
start_time = time.monotonic()
|
||||
time.sleep(timeout_s)
|
||||
total_runtime += time.monotonic() - start_time
|
||||
|
||||
return_code = run.poll()
|
||||
if return_code is None:
|
||||
# Send "SIGINT" to stop the run
|
||||
_print_message(f"Sending SIGUSR1 to run #{run_iter} w/ PID = {run.pid}")
|
||||
run.send_signal(signal.SIGUSR1)
|
||||
|
||||
# Make sure the process is stopped forcefully after a timeout.
|
||||
_kill_process_if_needed(run)
|
||||
else:
|
||||
_print_message("Run has already terminated!")
|
||||
break
|
||||
|
||||
# Check up on the results.
|
||||
results = ResultGrid(ExperimentAnalysis(str(storage_path / exp_name)))
|
||||
iters = [result.metrics.get("training_iteration", 0) for result in results]
|
||||
progress = sum(iters) / total_iters
|
||||
progress_history.append(progress)
|
||||
_print_message(
|
||||
f"Number of trials = {len(results)}\n"
|
||||
f"% completion = {progress} ({sum(iters)} iters / {total_iters})\n"
|
||||
f"Currently at {total_runtime:.2f} seconds"
|
||||
)
|
||||
|
||||
_print_message(
|
||||
f"Total number of restorations = {run_iter}\n"
|
||||
f"Total runtime = {total_runtime:.2f}\n"
|
||||
f"Return code = {return_code}"
|
||||
)
|
||||
test_end_time = time.monotonic()
|
||||
|
||||
assert progress == 1.0
|
||||
|
||||
# The script shouldn't have errored. (It should have finished by this point.)
|
||||
assert return_code == 0, (
|
||||
f"The script errored with return code: {return_code}.\n"
|
||||
f"Check the `{_RUN_SCRIPT_FILENAME}` script for any issues. "
|
||||
)
|
||||
|
||||
# Req 1: training progress persisted
|
||||
# Check that progress increases monotonically (we never go backwards/start from 0)
|
||||
assert np.all(np.diff(progress_history) >= 0), (
|
||||
"Expected progress to increase monotonically. Instead, got:\n"
|
||||
"{progress_history}"
|
||||
)
|
||||
|
||||
# Req 2: searcher state
|
||||
results = ResultGrid(ExperimentAnalysis(str(storage_path / exp_name)))
|
||||
# Check that all trials have unique ids assigned by the searcher (if applicable)
|
||||
ids = [result.config.get("id", -1) for result in results]
|
||||
ids = [id for id in ids if id >= 0]
|
||||
if ids:
|
||||
assert sorted(ids) == list(range(1, num_trials + 1)), (
|
||||
"Expected the searcher to assign increasing id for each trial, but got:"
|
||||
f"{ids}"
|
||||
)
|
||||
|
||||
# Req 3: callback state
|
||||
with open(callback_dump_file, "r") as f:
|
||||
callback_state = json.load(f)
|
||||
|
||||
trial_iters = callback_state["trial_iters"]
|
||||
for iters in trial_iters.values():
|
||||
# Check that the callback has data for each trial, for all iters
|
||||
# NOTE: There may be some duplicate data, due to the fact that
|
||||
# the callback will be updated on every `on_trial_result` hook,
|
||||
# but the trial may crash before the corresponding checkpoint gets processed.
|
||||
assert sorted(set(iters)) == list(
|
||||
range(1, iters_per_trial + 1)
|
||||
), f"Expected data from all iterations, but got: {iters}"
|
||||
|
||||
_print_message(f"Success! Test took {test_end_time - test_start_time:.2f} seconds.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,366 @@
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from unittest.mock import patch
|
||||
|
||||
from ray.air.integrations.comet import CometLoggerCallback
|
||||
|
||||
|
||||
class MockTrial(
|
||||
namedtuple("MockTrial", ["config", "trial_name", "trial_id", "logdir"])
|
||||
):
|
||||
def __hash__(self):
|
||||
return hash(self.trial_id)
|
||||
|
||||
def __str__(self):
|
||||
return self.trial_name
|
||||
|
||||
|
||||
class InitializationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.logger = CometLoggerCallback()
|
||||
|
||||
def test_class_variable_to_instance(self):
|
||||
"""Test that class variables get properly assigned to instance
|
||||
variables.
|
||||
"""
|
||||
logger = self.logger
|
||||
self.assertEqual(logger._to_exclude, logger._exclude_results)
|
||||
self.assertEqual(logger._to_system, logger._system_results)
|
||||
self.assertEqual(logger._to_other, logger._other_results)
|
||||
self.assertEqual(logger._to_episodes, logger._episode_results)
|
||||
|
||||
def test_configure_experiment_defaults(self):
|
||||
"""Test CometLoggerCallback._configure_experiment_defaults."""
|
||||
logger = self.logger
|
||||
|
||||
# Test that autologging features are properly disabled
|
||||
exclude = CometLoggerCallback._exclude_autolog
|
||||
for option in exclude:
|
||||
self.assertFalse(logger.experiment_kwargs.get(option))
|
||||
del logger
|
||||
|
||||
# Don't disable logging if user overwrites defaults by passing in args
|
||||
for include_option in exclude:
|
||||
# This unpacks to become e.g. CometLoggerCallback(log_env_cpu=True)
|
||||
logger = CometLoggerCallback(**{include_option: True})
|
||||
for option in exclude:
|
||||
if option == include_option:
|
||||
self.assertTrue(logger.experiment_kwargs.get(option))
|
||||
else:
|
||||
self.assertFalse(logger.experiment_kwargs.get(option))
|
||||
|
||||
|
||||
class HelperMethodTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.logger = CometLoggerCallback()
|
||||
|
||||
def test_check_key_name(self):
|
||||
|
||||
logger = self.logger
|
||||
# Return True when key == item
|
||||
self.assertTrue(logger._check_key_name("name", "name"))
|
||||
# Return True when key.startswith(item + "/")
|
||||
self.assertTrue(logger._check_key_name("name/", "name"))
|
||||
# Return False when item.startswith(key + "/")
|
||||
self.assertFalse(logger._check_key_name("name", "name/"))
|
||||
# Return False when key != item and not key.startswith(item."/")
|
||||
self.assertFalse(logger._check_key_name("name", "x"))
|
||||
|
||||
|
||||
@patch("comet_ml.OfflineExperiment")
|
||||
@patch("comet_ml.Experiment")
|
||||
class OnlineVsOfflineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.loggers = {
|
||||
"online": CometLoggerCallback(),
|
||||
"offline": CometLoggerCallback(online=False),
|
||||
}
|
||||
|
||||
self.trial = MockTrial({"p1": 1}, "trial_1", 1, "artifact")
|
||||
|
||||
def test_online_dispatch(self, experiment, offline_experiment):
|
||||
|
||||
# To start, there should be no experiments
|
||||
experiment.assert_not_called()
|
||||
offline_experiment.assert_not_called()
|
||||
|
||||
# Start online experiment
|
||||
logger = self.loggers["online"]
|
||||
logger.log_trial_start(self.trial)
|
||||
|
||||
# Check that Experiment was called and OfflineExperiment was not
|
||||
experiment.assert_called_once()
|
||||
offline_experiment.assert_not_called()
|
||||
|
||||
def test_offline_dispatch(self, experiment, offline_experiment):
|
||||
|
||||
# To start, there should be no experiments
|
||||
experiment.assert_not_called()
|
||||
offline_experiment.assert_not_called()
|
||||
|
||||
# Start online experiment
|
||||
logger = self.loggers["offline"]
|
||||
logger.log_trial_start(self.trial)
|
||||
|
||||
# Check that Experiment was called and OfflineExperiment was not
|
||||
experiment.assert_not_called()
|
||||
offline_experiment.assert_called_once()
|
||||
|
||||
|
||||
@patch("comet_ml.OfflineExperiment")
|
||||
@patch("comet_ml.Experiment")
|
||||
class LogTrialStartTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.loggers = {
|
||||
"online": CometLoggerCallback(),
|
||||
"offline": CometLoggerCallback(online=False),
|
||||
}
|
||||
|
||||
self.trials = [
|
||||
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
|
||||
MockTrial({"p1": 2}, "trial_2", 1, "artifact"),
|
||||
]
|
||||
|
||||
def test_existing_trialexperiment(self, experiment, offline_experiment):
|
||||
|
||||
mocks = {"online": experiment, "offline": offline_experiment}
|
||||
for option in ["online", "offline"]:
|
||||
logger = self.loggers[option]
|
||||
mock = mocks[option]
|
||||
|
||||
# This should create an experiment
|
||||
logger.log_trial_start(self.trials[0])
|
||||
mock.assert_called_once()
|
||||
|
||||
# This should NOT create an experiment because it's the same trial
|
||||
logger.log_trial_start(self.trials[0])
|
||||
mock.assert_called_once()
|
||||
|
||||
# This should create another new experiment
|
||||
logger.log_trial_start(self.trials[1])
|
||||
|
||||
# Number of times the mock was called
|
||||
num_calls = len(mock.call_args_list)
|
||||
|
||||
# Assert that Experiment/OfflineExperiment was called twice
|
||||
self.assertEqual(num_calls, 2)
|
||||
|
||||
def test_set_global_experiment(self, experiment, offline_experiment):
|
||||
for option in ["online", "offline"]:
|
||||
logger = self.loggers[option]
|
||||
with patch("comet_ml.config.set_global_experiment") as mock:
|
||||
logger.log_trial_start(self.trials[0])
|
||||
mock.assert_called_with(None)
|
||||
mock.assert_called_once()
|
||||
mock.reset_mock()
|
||||
|
||||
def test_experiment_addtags(self, experiment, offline_experiment):
|
||||
logger = self.loggers["online"]
|
||||
logger.log_trial_start(self.trials[0])
|
||||
experiment.return_value.add_tags.assert_called_with(logger.tags)
|
||||
|
||||
def test_experiment_setname(self, experiment, offline_experiment):
|
||||
logger = self.loggers["online"]
|
||||
trial = self.trials[0]
|
||||
logger.log_trial_start(trial)
|
||||
experiment.return_value.set_name.assert_called_with(trial.trial_name)
|
||||
|
||||
def test_experiment_logparams(self, experiment, offline_experiment):
|
||||
logger = self.loggers["online"]
|
||||
trial = self.trials[0]
|
||||
logger.log_trial_start(trial)
|
||||
config = trial.config.copy()
|
||||
config.pop("callbacks", None)
|
||||
experiment.return_value.log_parameters.assert_called_with(config)
|
||||
|
||||
|
||||
class ExperimentKwargsTest(unittest.TestCase):
|
||||
@patch("comet_ml.Experiment")
|
||||
def test_kwargs_passthrough(self, experiment):
|
||||
"""Test that additional keyword arguments to CometLoggerCallback get
|
||||
passed through to comet_ml.Experiment on log_trial_start
|
||||
"""
|
||||
experiment_kwargs = {"kwarg_1": "val_1"}
|
||||
logger = CometLoggerCallback(**experiment_kwargs)
|
||||
trial = MockTrial({"parameter": 1}, "trial2", 1, "artifact")
|
||||
logger.log_trial_start(trial)
|
||||
|
||||
# These are the default kwargs that get passed to create the experiment
|
||||
expected_kwargs = {kwarg: False for kwarg in logger._exclude_autolog}
|
||||
expected_kwargs.update(experiment_kwargs)
|
||||
|
||||
experiment.assert_called_with(**expected_kwargs)
|
||||
|
||||
|
||||
@patch("comet_ml.Experiment")
|
||||
class LogTrialResultTests(unittest.TestCase):
|
||||
"""
|
||||
* test log_others logs
|
||||
* test log_system logs
|
||||
* test log_curve logs
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.logger = CometLoggerCallback()
|
||||
self.trials = [
|
||||
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
|
||||
MockTrial({"p1": 2}, "trial_2", 1, "artifact"),
|
||||
]
|
||||
self.result = {
|
||||
"config": {"p1": 1},
|
||||
"node_ip": "0.0.0.0",
|
||||
"hostname": "hostname_val",
|
||||
"pid": "1234",
|
||||
"date": "2000-01-01",
|
||||
"experiment_id": "1234",
|
||||
"trial_id": 1,
|
||||
"experiment_tag": "tag1",
|
||||
"hist_stats/episode_reward": [1, 0, 1, -1, 0, 1],
|
||||
"hist_stats/episode_lengths": [1, 2, 3, 4, 5, 6],
|
||||
"metric1": 0.8,
|
||||
"metric2": 1,
|
||||
"metric3": None,
|
||||
"training_iteration": 0,
|
||||
}
|
||||
|
||||
def test_log_parameters(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
result = self.result.copy()
|
||||
|
||||
# Check parameters are logged properly.
|
||||
logger.log_trial_result(1, trial, self.result)
|
||||
|
||||
config_update = result.copy().pop("config", {})
|
||||
config_update.pop("callbacks", None) # Remove callbacks
|
||||
|
||||
experiment.return_value.log_parameters.assert_any_call(config_update)
|
||||
|
||||
def test_log_metrics(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
result = self.result.copy()
|
||||
step = result["training_iteration"]
|
||||
|
||||
logger.log_trial_result(1, trial, self.result)
|
||||
result_metrics = {
|
||||
"metric1": 0.8,
|
||||
"metric2": 1,
|
||||
"metric3": None,
|
||||
"training_iteration": 0,
|
||||
}
|
||||
|
||||
method = experiment.return_value.log_metrics
|
||||
method.assert_any_call(result_metrics, step=step)
|
||||
|
||||
def test_log_other(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
result = self.result.copy()
|
||||
|
||||
logger.log_trial_result(1, trial, result)
|
||||
result_other = {
|
||||
"experiment_id": "1234",
|
||||
"trial_id": 1,
|
||||
"experiment_tag": "tag1",
|
||||
}
|
||||
method = experiment.return_value.log_others
|
||||
|
||||
# for k,v in result_other.items():
|
||||
method.assert_any_call(result_other)
|
||||
|
||||
def test_log_system(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
result = self.result.copy()
|
||||
|
||||
logger.log_trial_result(1, trial, result)
|
||||
result_system = {
|
||||
"node_ip": "0.0.0.0",
|
||||
"hostname": "hostname_val",
|
||||
"pid": "1234",
|
||||
"date": "2000-01-01",
|
||||
}
|
||||
method = experiment.return_value.log_system_info
|
||||
for k, v in result_system.items():
|
||||
method.assert_any_call(k, v)
|
||||
|
||||
def test_log_curve(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
|
||||
# Check parameters are logged properly.
|
||||
result = self.result
|
||||
step = result["training_iteration"]
|
||||
logger.log_trial_result(1, trial, result)
|
||||
|
||||
results_curve = {
|
||||
"hist_stats/episode_reward": [1, 0, 1, -1, 0, 1],
|
||||
"hist_stats/episode_lengths": [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
|
||||
method = experiment.return_value.log_curve
|
||||
print(method.call_args_list)
|
||||
for k, v in results_curve.items():
|
||||
|
||||
method.assert_any_call(k, x=range(len(v)), y=v, step=step)
|
||||
|
||||
|
||||
@patch("comet_ml.Experiment")
|
||||
class LogTrialEndTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.logger = CometLoggerCallback()
|
||||
self.trials = [
|
||||
MockTrial({"p1": 1}, "trial_1", 1, "artifact"),
|
||||
MockTrial({"p1": 2}, "trial_2", 2, "artifact"),
|
||||
MockTrial({"p1": 2}, "trial_3", 3, "artifact"),
|
||||
]
|
||||
|
||||
def test_not_started_exception(self, experiment):
|
||||
logger = self.logger
|
||||
with self.assertRaises(KeyError):
|
||||
logger.log_trial_end(self.trials[0])
|
||||
|
||||
def test_repeat_throws_error(self, experiment):
|
||||
logger = self.logger
|
||||
trial = self.trials[0]
|
||||
|
||||
logger.log_trial_start(trial)
|
||||
logger.log_trial_end(trial)
|
||||
with self.assertRaises(KeyError):
|
||||
logger.log_trial_end(trial)
|
||||
|
||||
def test_log_trial_end(self, experiment):
|
||||
logger = self.logger
|
||||
trials = self.trials
|
||||
method = experiment.return_value.end
|
||||
|
||||
# Should not have ended yet
|
||||
method.assert_not_called()
|
||||
|
||||
for trial in trials:
|
||||
logger.log_trial_start(trial)
|
||||
logger.log_trial_end(trial)
|
||||
|
||||
self.assertEqual(len(method.call_args_list), len(trials))
|
||||
|
||||
def test_del(self, experiment):
|
||||
logger = self.logger
|
||||
|
||||
for trial in self.trials:
|
||||
logger.log_trial_start(trial)
|
||||
|
||||
end = experiment.return_value.end
|
||||
end.assert_not_called()
|
||||
|
||||
logger.__del__()
|
||||
|
||||
self.assertEqual(len(end.call_args_list), len(self.trials))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,442 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from mlflow.tracking import MlflowClient
|
||||
|
||||
import ray
|
||||
from ray._private.dict import flatten_dict
|
||||
from ray.air._internal.mlflow import _MLflowLoggerUtil
|
||||
from ray.air.integrations.mlflow import MLflowLoggerCallback, _NoopModule, setup_mlflow
|
||||
from ray.train.torch import TorchTrainer
|
||||
from ray.tune import Tuner
|
||||
|
||||
|
||||
class MockTrial(
|
||||
namedtuple("MockTrial", ["config", "trial_name", "trial_id", "local_path"])
|
||||
):
|
||||
def __hash__(self):
|
||||
return hash(self.trial_id)
|
||||
|
||||
def __str__(self):
|
||||
return self.trial_name
|
||||
|
||||
|
||||
class Mock_MLflowLoggerUtil(_MLflowLoggerUtil):
|
||||
def save_artifacts(self, dir, run_id):
|
||||
self.artifact_saved = True
|
||||
self.artifact_info = {"dir": dir, "run_id": run_id}
|
||||
|
||||
|
||||
def clear_env_vars():
|
||||
os.environ.pop("MLFLOW_EXPERIMENT_NAME", None)
|
||||
os.environ.pop("MLFLOW_EXPERIMENT_ID", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
"""Automatically start and stop Ray for each test."""
|
||||
ray.init(num_cpus=4)
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_setup_mlflow_in_train_worker(ray_start_4_cpus):
|
||||
"""Test that setup_mlflow works in a Train worker."""
|
||||
|
||||
def train_func(config):
|
||||
setup_mlflow(
|
||||
experiment_name="test_exp",
|
||||
create_experiment_if_not_exists=True,
|
||||
)
|
||||
|
||||
trainer = TorchTrainer(train_func)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_setup_mlflow_in_tune_trial(ray_start_4_cpus):
|
||||
"""Test that setup_mlflow works in a Tune trial."""
|
||||
|
||||
def train_func(config):
|
||||
setup_mlflow(
|
||||
experiment_name="test_exp",
|
||||
create_experiment_if_not_exists=True,
|
||||
)
|
||||
|
||||
tuner = Tuner(train_func)
|
||||
result_grid = tuner.fit()
|
||||
|
||||
assert all(res.error is None for res in result_grid)
|
||||
|
||||
|
||||
class MLflowTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracking_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
|
||||
self.registry_uri = "sqlite:///" + tempfile.mkdtemp() + "/mlflow.sqlite"
|
||||
|
||||
client = MlflowClient(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
client.create_experiment(name="existing_experiment")
|
||||
# Mlflow > 2 creates a "Default" experiment which has ID 0, so we start our
|
||||
# test with ID 1.
|
||||
assert client.get_experiment_by_name("existing_experiment").experiment_id == "1"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
pass
|
||||
|
||||
def testMlFlowLoggerCallbackConfig(self):
|
||||
# Explicitly pass in all args.
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri,
|
||||
registry_uri=self.registry_uri,
|
||||
experiment_name="test_exp",
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(
|
||||
logger.mlflow_util._mlflow.get_tracking_uri(), self.tracking_uri
|
||||
)
|
||||
self.assertEqual(
|
||||
logger.mlflow_util._mlflow.get_registry_uri(), self.registry_uri
|
||||
)
|
||||
self.assertListEqual(
|
||||
[e.name for e in logger.mlflow_util._mlflow.search_experiments()],
|
||||
["test_exp", "existing_experiment", "Default"],
|
||||
)
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "2")
|
||||
|
||||
# Check if client recognizes already existing experiment.
|
||||
logger = MLflowLoggerCallback(
|
||||
experiment_name="existing_experiment",
|
||||
tracking_uri=self.tracking_uri,
|
||||
registry_uri=self.registry_uri,
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
||||
|
||||
# Pass in experiment name as env var.
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "2")
|
||||
|
||||
# Pass in existing experiment name as env var.
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
||||
|
||||
# Pass in existing experiment id as env var.
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
||||
|
||||
# Pass in non existing experiment id as env var.
|
||||
# This should create a new experiment.
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "500"
|
||||
with self.assertRaises(ValueError):
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
logger.setup()
|
||||
|
||||
# Experiment id env var should take precedence over name env var.
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_exp"
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.mlflow_util.experiment_id, "1")
|
||||
|
||||
# Using tags
|
||||
tags = {"user_name": "John", "git_commit_hash": "abc123"}
|
||||
clear_env_vars()
|
||||
os.environ["MLFLOW_EXPERIMENT_NAME"] = "test_tags"
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "1"
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri, registry_uri=self.registry_uri, tags=tags
|
||||
)
|
||||
logger.setup()
|
||||
self.assertEqual(logger.tags, tags)
|
||||
|
||||
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
|
||||
def testMlFlowLoggerLogging(self):
|
||||
clear_env_vars()
|
||||
trial_config = {"par1": "a", "par2": "b"}
|
||||
trial = MockTrial(trial_config, "trial1", 0, "artifact")
|
||||
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri,
|
||||
registry_uri=self.registry_uri,
|
||||
experiment_name="test1",
|
||||
save_artifact=True,
|
||||
tags={"hello": "world"},
|
||||
)
|
||||
logger.setup()
|
||||
|
||||
# Check if run is created with proper tags.
|
||||
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
||||
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
|
||||
self.assertEqual(len(all_runs), 1)
|
||||
# all_runs is a pandas dataframe.
|
||||
all_runs = all_runs.to_dict(orient="records")
|
||||
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
|
||||
self.assertDictEqual(
|
||||
run.data.tags,
|
||||
{"hello": "world", "trial_name": "trial1", "mlflow.runName": "trial1"},
|
||||
)
|
||||
self.assertEqual(logger._trial_runs[trial], run.info.run_id)
|
||||
# Params should be logged.
|
||||
self.assertDictEqual(run.data.params, trial_config)
|
||||
|
||||
# When same trial is started again, new run should not be created.
|
||||
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
||||
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=["2"])
|
||||
self.assertEqual(len(all_runs), 1)
|
||||
|
||||
# Check metrics are logged properly.
|
||||
result = {
|
||||
"metric1": 0.8,
|
||||
"metric2": 1,
|
||||
"metric3": None,
|
||||
"training_iteration": 0,
|
||||
}
|
||||
logger.on_trial_result(0, [], trial, result)
|
||||
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
||||
# metric3 is not logged since it cannot be converted to float.
|
||||
self.assertDictEqual(
|
||||
run.data.metrics, {"metric1": 0.8, "metric2": 1.0, "training_iteration": 0}
|
||||
)
|
||||
|
||||
# Check that artifact is logged on termination.
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
self.assertTrue(logger.mlflow_util.artifact_saved)
|
||||
self.assertDictEqual(
|
||||
logger.mlflow_util.artifact_info,
|
||||
{"dir": "artifact", "run_id": run.info.run_id},
|
||||
)
|
||||
|
||||
# Check if params are logged at the end.
|
||||
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
||||
self.assertDictEqual(run.data.params, trial_config)
|
||||
|
||||
@patch("ray.air.integrations.mlflow._MLflowLoggerUtil", Mock_MLflowLoggerUtil)
|
||||
def testMlFlowLoggerLogging_logAtEnd(self):
|
||||
clear_env_vars()
|
||||
trial_config = {"par1": "a", "par2": "b"}
|
||||
trial = MockTrial(trial_config, "trial1", 0, "artifact")
|
||||
|
||||
logger = MLflowLoggerCallback(
|
||||
tracking_uri=self.tracking_uri,
|
||||
registry_uri=self.registry_uri,
|
||||
experiment_name="test_log_at_end",
|
||||
tags={"hello": "world"},
|
||||
log_params_on_trial_end=True,
|
||||
)
|
||||
logger.setup()
|
||||
exp_id = logger.mlflow_util.experiment_id
|
||||
|
||||
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
||||
all_runs = logger.mlflow_util._mlflow.search_runs(experiment_ids=[exp_id])
|
||||
self.assertEqual(len(all_runs), 1)
|
||||
# all_runs is a pandas dataframe.
|
||||
all_runs = all_runs.to_dict(orient="records")
|
||||
run = logger.mlflow_util._mlflow.get_run(all_runs[0]["run_id"])
|
||||
|
||||
# Params should NOT be logged at start.
|
||||
self.assertDictEqual(run.data.params, {})
|
||||
|
||||
# Check that params are logged at the end.
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
run = logger.mlflow_util._mlflow.get_run(run_id=run.info.run_id)
|
||||
self.assertDictEqual(run.data.params, trial_config)
|
||||
|
||||
def testMlFlowSetupExplicit(self):
|
||||
clear_env_vars()
|
||||
trial_config = {"par1": 4, "par2": 9.0}
|
||||
|
||||
# No MLflow config passed in.
|
||||
with self.assertRaises(ValueError):
|
||||
setup_mlflow(trial_config)
|
||||
|
||||
# Invalid experiment-id
|
||||
with self.assertRaises(ValueError):
|
||||
setup_mlflow(trial_config, experiment_id="500")
|
||||
|
||||
# Set to experiment that does not already exist.
|
||||
with self.assertRaises(ValueError):
|
||||
setup_mlflow(
|
||||
trial_config,
|
||||
experiment_id="500",
|
||||
experiment_name="new_experiment",
|
||||
tracking_uri=self.tracking_uri,
|
||||
)
|
||||
|
||||
mlflow = setup_mlflow(
|
||||
trial_config,
|
||||
experiment_id="500",
|
||||
experiment_name="existing_experiment",
|
||||
tracking_uri=self.tracking_uri,
|
||||
)
|
||||
mlflow.end_run()
|
||||
|
||||
@patch("ray.train.get_context")
|
||||
def testMlFlowSetupRankNonRankZero(self, mock_get_context):
|
||||
"""Assert that non-rank-0 workers get a noop module"""
|
||||
mock_context = MagicMock()
|
||||
mock_context.get_world_rank.return_value = 1
|
||||
|
||||
mock_get_context.return_value = mock_context
|
||||
|
||||
mlflow = setup_mlflow({})
|
||||
assert isinstance(mlflow, _NoopModule)
|
||||
|
||||
mlflow.log_metrics()
|
||||
mlflow.sklearn.save_model(None, "model_directory")
|
||||
|
||||
|
||||
class MLflowUtilTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dirpath = tempfile.mkdtemp()
|
||||
import mlflow
|
||||
|
||||
mlflow.set_tracking_uri("sqlite:///" + self.dirpath + "/mlflow.sqlite")
|
||||
mlflow.create_experiment(name="existing_experiment")
|
||||
|
||||
self.mlflow_util = _MLflowLoggerUtil()
|
||||
self.tracking_uri = mlflow.get_tracking_uri()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dirpath)
|
||||
|
||||
def test_experiment_id(self):
|
||||
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri, experiment_id="0")
|
||||
assert self.mlflow_util.experiment_id == "0"
|
||||
|
||||
def test_experiment_id_env_var(self):
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
|
||||
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
|
||||
assert self.mlflow_util.experiment_id == "0"
|
||||
del os.environ["MLFLOW_EXPERIMENT_ID"]
|
||||
|
||||
def test_experiment_name(self):
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name="existing_experiment"
|
||||
)
|
||||
assert self.mlflow_util.experiment_id == "1"
|
||||
|
||||
def test_run_started_with_correct_experiment(self):
|
||||
experiment_name = "my_experiment_name"
|
||||
# Make sure run is started under the correct experiment.
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name=experiment_name
|
||||
)
|
||||
run = self.mlflow_util.start_run(set_active=True)
|
||||
assert (
|
||||
run.info.experiment_id
|
||||
== self.mlflow_util._mlflow.get_experiment_by_name(
|
||||
experiment_name
|
||||
).experiment_id
|
||||
)
|
||||
|
||||
self.mlflow_util.end_run()
|
||||
|
||||
def test_experiment_name_env_var(self):
|
||||
os.environ["MLFLOW_EXPERIMENT_NAME"] = "existing_experiment"
|
||||
self.mlflow_util.setup_mlflow(tracking_uri=self.tracking_uri)
|
||||
assert self.mlflow_util.experiment_id == "1"
|
||||
del os.environ["MLFLOW_EXPERIMENT_NAME"]
|
||||
|
||||
def test_id_precedence(self):
|
||||
os.environ["MLFLOW_EXPERIMENT_ID"] = "0"
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
||||
)
|
||||
assert self.mlflow_util.experiment_id == "0"
|
||||
del os.environ["MLFLOW_EXPERIMENT_ID"]
|
||||
|
||||
def test_new_experiment(self):
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
||||
)
|
||||
assert self.mlflow_util.experiment_id == "2"
|
||||
|
||||
def test_setup_fail(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri,
|
||||
experiment_name="new_experiment2",
|
||||
create_experiment_if_not_exists=False,
|
||||
)
|
||||
|
||||
def test_log_params(self):
|
||||
params = {"a": "a", "x": {"y": "z"}}
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
||||
)
|
||||
run = self.mlflow_util.start_run()
|
||||
run_id = run.info.run_id
|
||||
self.mlflow_util.log_params(params_to_log=params, run_id=run_id)
|
||||
|
||||
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
||||
assert run.data.params == flatten_dict(params)
|
||||
|
||||
params2 = {"b": "b"}
|
||||
self.mlflow_util.start_run(set_active=True)
|
||||
self.mlflow_util.log_params(params_to_log=params2, run_id=run_id)
|
||||
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
||||
assert run.data.params == flatten_dict(
|
||||
{
|
||||
**params,
|
||||
**params2,
|
||||
}
|
||||
)
|
||||
|
||||
self.mlflow_util.end_run()
|
||||
|
||||
def test_log_metrics(self):
|
||||
metrics = {"a": 1.0, "x": {"y": 2.0}}
|
||||
self.mlflow_util.setup_mlflow(
|
||||
tracking_uri=self.tracking_uri, experiment_name="new_experiment"
|
||||
)
|
||||
run = self.mlflow_util.start_run()
|
||||
run_id = run.info.run_id
|
||||
self.mlflow_util.log_metrics(metrics_to_log=metrics, run_id=run_id, step=0)
|
||||
|
||||
run = self.mlflow_util._mlflow.get_run(run_id=run_id)
|
||||
assert run.data.metrics == flatten_dict(metrics)
|
||||
|
||||
metrics2 = {"b": 1.0}
|
||||
self.mlflow_util.start_run(set_active=True)
|
||||
self.mlflow_util.log_metrics(metrics_to_log=metrics2, run_id=run_id, step=0)
|
||||
assert self.mlflow_util._mlflow.get_run(
|
||||
run_id=run_id
|
||||
).data.metrics == flatten_dict(
|
||||
{
|
||||
**metrics,
|
||||
**metrics2,
|
||||
}
|
||||
)
|
||||
self.mlflow_util.end_run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for wandb integration.
|
||||
|
||||
Note: These tests use a set of mocked APIs:
|
||||
- _MockWandbAPI: Mocks wandb API calls (ex: wandb.init).
|
||||
- _MockWandbLoggingActor: The same as the regular _WandbLoggingActor,
|
||||
except using the mocked wandb API
|
||||
- WandbTestExperimentLogger: Thin subclass of `WandbLoggerCallback` to use for testing.
|
||||
Provides a helper `trial_logging_actors` property that can be used to
|
||||
access attributes of the remote actors for assertions.
|
||||
- Use the `get_mock_wandb_logger` helper method to create a logger with
|
||||
a custom mock wandb API class. (Ex: If you want to override some wandb API methods.)
|
||||
|
||||
Template for testing with these mocks:
|
||||
|
||||
wandb_logger_kwargs = {}
|
||||
logger = get_mock_wandb_logger(mock_api_cls=_MockWandbAPI, **wandb_logger_kwargs)
|
||||
logger.setup()
|
||||
|
||||
# From now on, the API key is in the env variable.
|
||||
# Start the remote logging actor
|
||||
logger.on_trial_start(0, [], trial)
|
||||
# Log some results
|
||||
result = {}
|
||||
logger.on_trial_result(0, [], trial, result)
|
||||
# Send a STOP signal to the logging actor
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
# This will wait for the logging actor to finish + cleanup
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
|
||||
# Now, we can access properties of the logging actors
|
||||
# (must happen after `on_trial_end` and `on_experiment_end`)
|
||||
logger_state = logger.trial_logging_actor_states[trial]
|
||||
# logger_state.logs, logger_state.config, logger_state.kwargs, ...
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air.integrations.wandb import (
|
||||
WANDB_ENV_VAR,
|
||||
WANDB_GROUP_ENV_VAR,
|
||||
WANDB_POPULATE_RUN_LOCATION_HOOK,
|
||||
WANDB_PROJECT_ENV_VAR,
|
||||
WANDB_SETUP_API_KEY_HOOK,
|
||||
RunDisabled,
|
||||
WandbLoggerCallback,
|
||||
_QueueItem,
|
||||
_WandbLoggingActor,
|
||||
setup_wandb,
|
||||
)
|
||||
from ray.air.tests.mocked_wandb_integration import (
|
||||
Trial,
|
||||
WandbTestExperimentLogger,
|
||||
_MockWandbAPI,
|
||||
_MockWandbLoggingActor,
|
||||
get_mock_wandb_logger,
|
||||
)
|
||||
from ray.exceptions import RayActorError
|
||||
from ray.tune.execution.placement_groups import PlacementGroupFactory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial():
|
||||
trial_config = {"par1": 4, "par2": 9.12345678}
|
||||
trial = Trial(
|
||||
trial_config,
|
||||
0,
|
||||
"trial_0",
|
||||
"trainable",
|
||||
PlacementGroupFactory([{"CPU": 1}]),
|
||||
"/tmp",
|
||||
)
|
||||
yield trial
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def wandb_env():
|
||||
"""Clean up W&B env var before and after each test.
|
||||
|
||||
Even if we use monkeypatch in the test, this is useful to remove environment
|
||||
variables that are set on the laptop when running tests locally.
|
||||
"""
|
||||
if WANDB_ENV_VAR in os.environ:
|
||||
del os.environ[WANDB_ENV_VAR]
|
||||
yield
|
||||
if WANDB_ENV_VAR in os.environ:
|
||||
del os.environ[WANDB_ENV_VAR]
|
||||
|
||||
|
||||
def fake_wandb_populate_run_location_hook():
|
||||
"""Fake user-provided hook to populate W&B environment variables."""
|
||||
os.environ[WANDB_PROJECT_ENV_VAR] = "test_project"
|
||||
os.environ[WANDB_GROUP_ENV_VAR] = "test_group"
|
||||
|
||||
|
||||
FAKE_WANDB_POPULATE_RUN_LOCATION_HOOK_IMPORT_PATH = (
|
||||
"ray.air.tests.test_integration_wandb.fake_wandb_populate_run_location_hook"
|
||||
)
|
||||
|
||||
|
||||
class TestWandbLogger:
|
||||
def test_wandb_logger_project_group(self, monkeypatch):
|
||||
monkeypatch.setenv(WANDB_PROJECT_ENV_VAR, "test_project_from_env_var")
|
||||
monkeypatch.setenv(WANDB_GROUP_ENV_VAR, "test_group_from_env_var")
|
||||
# Read project and group name from environment variable
|
||||
logger = WandbTestExperimentLogger(api_key="1234")
|
||||
logger.setup()
|
||||
assert logger.project == "test_project_from_env_var"
|
||||
assert logger.group == "test_group_from_env_var"
|
||||
|
||||
def test_wandb_logger_api_key_config(self, monkeypatch):
|
||||
# No API key
|
||||
with pytest.raises(ValueError):
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
|
||||
# Fetch API key from argument even if external hook and WANDB_ENV_VAR set
|
||||
monkeypatch.setenv(
|
||||
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
WANDB_ENV_VAR,
|
||||
"abcde",
|
||||
)
|
||||
# API Key in config
|
||||
logger = WandbTestExperimentLogger(project="test_project", api_key="1234")
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_ENV_VAR] == "1234"
|
||||
|
||||
def test_wandb_logger_api_key_file(self, monkeypatch):
|
||||
# Fetch API key from file even if external hook and WANDB_ENV_VAR set
|
||||
monkeypatch.setenv(
|
||||
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
WANDB_ENV_VAR,
|
||||
"abcde",
|
||||
)
|
||||
# API Key file
|
||||
with tempfile.NamedTemporaryFile("wt") as fp:
|
||||
fp.write("5678")
|
||||
fp.flush()
|
||||
|
||||
logger = WandbTestExperimentLogger(
|
||||
project="test_project", api_key_file=fp.name
|
||||
)
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_ENV_VAR] == "5678"
|
||||
|
||||
def test_wandb_logger_api_key_env_var(self, monkeypatch):
|
||||
# API Key from env var takes precedence over external hook and
|
||||
# logged in W&B API key
|
||||
monkeypatch.setenv(
|
||||
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
WANDB_ENV_VAR,
|
||||
"1234",
|
||||
)
|
||||
mock_wandb = Mock(api=Mock(api_key="efgh"))
|
||||
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_ENV_VAR] == "1234"
|
||||
mock_wandb.ensure_configured.assert_not_called()
|
||||
|
||||
def test_wandb_logger_api_key_external_hook(self, monkeypatch):
|
||||
# API Key from external hook if API key not provided through
|
||||
# argument or WANDB_ENV_VAR and user not already logged in to W&B
|
||||
monkeypatch.setenv(
|
||||
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
|
||||
)
|
||||
|
||||
mock_wandb = Mock(api=Mock(api_key=None))
|
||||
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_ENV_VAR] == "abcd"
|
||||
mock_wandb.ensure_configured.assert_called_once()
|
||||
|
||||
mock_wandb = Mock(ensure_configured=Mock(side_effect=AttributeError()))
|
||||
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_ENV_VAR] == "abcd"
|
||||
|
||||
def test_wandb_logger_api_key_from_wandb_login(self, monkeypatch):
|
||||
# No API key should get set if user is already logged in to W&B
|
||||
# and they didn't pass API key through argument or env var.
|
||||
# External hook should not be called because user already logged
|
||||
# in takes precedence.
|
||||
monkeypatch.setenv(
|
||||
WANDB_SETUP_API_KEY_HOOK, "ray._private.test_utils.wandb_setup_api_key_hook"
|
||||
)
|
||||
mock_wandb = Mock()
|
||||
with patch.multiple("ray.air.integrations.wandb", wandb=mock_wandb):
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
assert os.environ.get(WANDB_ENV_VAR) is None
|
||||
mock_wandb.ensure_configured.assert_called_once()
|
||||
|
||||
def test_wandb_logger_run_location_external_hook(self, monkeypatch):
|
||||
with patch.dict(os.environ):
|
||||
# No project
|
||||
with pytest.raises(ValueError):
|
||||
logger = WandbTestExperimentLogger(api_key="1234")
|
||||
logger.setup()
|
||||
|
||||
# Project and group env vars from external hook
|
||||
monkeypatch.setenv(
|
||||
WANDB_POPULATE_RUN_LOCATION_HOOK,
|
||||
FAKE_WANDB_POPULATE_RUN_LOCATION_HOOK_IMPORT_PATH,
|
||||
)
|
||||
logger = WandbTestExperimentLogger(api_key="1234")
|
||||
logger.setup()
|
||||
assert os.environ[WANDB_PROJECT_ENV_VAR] == "test_project"
|
||||
assert os.environ[WANDB_GROUP_ENV_VAR] == "test_group"
|
||||
|
||||
def test_wandb_logger_start(self, monkeypatch, trial):
|
||||
monkeypatch.setenv(WANDB_ENV_VAR, "9012")
|
||||
# API Key in env
|
||||
logger = WandbTestExperimentLogger(project="test_project")
|
||||
logger.setup()
|
||||
# From now on, the API key is in the env variable.
|
||||
logger.log_trial_start(trial)
|
||||
logger.log_trial_end(trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
|
||||
logger_state = logger.trial_logging_actor_states[trial]
|
||||
assert logger_state.kwargs["project"] == "test_project"
|
||||
assert logger_state.kwargs["id"] == trial.trial_id
|
||||
assert logger_state.kwargs["name"] == trial.trial_name
|
||||
assert logger_state.kwargs["group"] == trial.experiment_dir_name
|
||||
assert "config" in logger_state.exclude
|
||||
|
||||
del logger
|
||||
|
||||
# log config.
|
||||
logger = WandbTestExperimentLogger(project="test_project", log_config=True)
|
||||
logger.log_trial_start(trial)
|
||||
logger.log_trial_end(trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
|
||||
logger_state = logger.trial_logging_actor_states[trial]
|
||||
assert "config" not in logger_state.exclude
|
||||
assert "metric" not in logger_state.exclude
|
||||
|
||||
del logger
|
||||
|
||||
# Exclude metric.
|
||||
logger = WandbTestExperimentLogger(project="test_project", excludes=["metric"])
|
||||
logger.log_trial_start(trial)
|
||||
logger.log_trial_end(trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
|
||||
logger_state = logger.trial_logging_actor_states[trial]
|
||||
assert "config" in logger_state.exclude
|
||||
assert "metric" in logger_state.exclude
|
||||
|
||||
del logger
|
||||
|
||||
def test_wandb_logger_reporting(self, trial):
|
||||
logger = WandbTestExperimentLogger(
|
||||
project="test_project", api_key="1234", excludes=["metric2"]
|
||||
)
|
||||
logger.on_trial_start(0, [], trial)
|
||||
r1 = {
|
||||
"metric1": 0.8,
|
||||
"metric2": 1.4,
|
||||
"metric3": np.asarray(32.0),
|
||||
"metric4": np.float32(32.0),
|
||||
"const": "text",
|
||||
"config": trial.config,
|
||||
}
|
||||
logger.on_trial_result(0, [], trial, r1)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
logged = logger.trial_logging_actor_states[trial].logs[0]
|
||||
assert "metric1" in logged
|
||||
assert "metric2" not in logged
|
||||
assert "metric3" in logged
|
||||
assert "metric4" in logged
|
||||
assert "const" not in logged
|
||||
assert "config" not in logged
|
||||
|
||||
def test_wandb_logger_auto_config_keys(self, trial):
|
||||
logger = WandbTestExperimentLogger(project="test_project", api_key="1234")
|
||||
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
||||
result = {key: 0 for key in WandbLoggerCallback.AUTO_CONFIG_KEYS}
|
||||
logger.on_trial_result(0, [], trial, result)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
config = logger.trial_logging_actor_states[trial].config
|
||||
# The results in `AUTO_CONFIG_KEYS` should be saved as training configuration
|
||||
# instead of output metrics.
|
||||
assert set(WandbLoggerCallback.AUTO_CONFIG_KEYS) < set(config)
|
||||
|
||||
def test_wandb_logger_exclude_config(self):
|
||||
trial = Trial(
|
||||
config={"param1": 0, "param2": 0},
|
||||
trial_id=0,
|
||||
trial_name="trial_0",
|
||||
experiment_dir_name="trainable",
|
||||
placement_group_factory=PlacementGroupFactory([{"CPU": 1}]),
|
||||
local_path=tempfile.gettempdir(),
|
||||
)
|
||||
logger = WandbTestExperimentLogger(
|
||||
project="test_project",
|
||||
api_key="1234",
|
||||
excludes=(["param2"] + WandbLoggerCallback.AUTO_CONFIG_KEYS),
|
||||
)
|
||||
logger.on_trial_start(iteration=0, trials=[], trial=trial)
|
||||
|
||||
# We need to test that `excludes` also applies to `AUTO_CONFIG_KEYS`.
|
||||
result = {key: 0 for key in WandbLoggerCallback.AUTO_CONFIG_KEYS}
|
||||
logger.on_trial_result(0, [], trial, result)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
|
||||
config = logger.trial_logging_actor_states[trial].config
|
||||
assert set(config) == {"param1"}
|
||||
|
||||
def test_set_serializability_result(self, trial):
|
||||
"""Tests that objects that contain sets can be serialized by wandb."""
|
||||
logger = WandbTestExperimentLogger(
|
||||
project="test_project", api_key="1234", excludes=["metric2"]
|
||||
)
|
||||
logger.on_trial_start(0, [], trial)
|
||||
|
||||
# Testing for https://github.com/ray-project/ray/issues/28541
|
||||
rllib_result = {
|
||||
"env": "simple_spread",
|
||||
"framework": "torch",
|
||||
"num_gpus": 1,
|
||||
"num_workers": 20,
|
||||
"num_envs_per_env_runner": 1,
|
||||
"compress_observations": True,
|
||||
"lambda": 0.99,
|
||||
"train_batch_size": 512,
|
||||
"sgd_minibatch_size": 32,
|
||||
"num_sgd_iter": 5,
|
||||
"batch_mode": "truncate_episodes",
|
||||
"entropy_coeff": 0.01,
|
||||
"lr": 2e-05,
|
||||
"multiagent": {
|
||||
"policies": {"shared_policy"},
|
||||
"policy_mapping_fn": lambda x: x,
|
||||
},
|
||||
}
|
||||
logger.on_trial_result(0, [], trial, rllib_result)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
logged = logger.trial_logging_actor_states[trial].logs[0]
|
||||
assert logged != "serialization error"
|
||||
|
||||
def test_wandb_logging_actor_api_key(self, trial, monkeypatch):
|
||||
"""Tests that the wandb API key get propagated as an environment variable to
|
||||
the remote logging actors."""
|
||||
|
||||
def mock_run(actor_cls):
|
||||
return os.environ.get(WANDB_ENV_VAR)
|
||||
|
||||
monkeypatch.setattr(_MockWandbLoggingActor, "run", mock_run)
|
||||
|
||||
logger = WandbLoggerCallback(
|
||||
project="test_project", api_key="1234", excludes=["metric2"]
|
||||
)
|
||||
logger._logger_actor_cls = _MockWandbLoggingActor
|
||||
logger.setup()
|
||||
logger.log_trial_start(trial)
|
||||
actor_env_var = ray.get(logger._trial_logging_futures[trial])
|
||||
assert actor_env_var == "1234"
|
||||
|
||||
def test_wandb_finish(self, trial, tmp_path):
|
||||
"""Test that logging actors are cleaned up upon experiment completion."""
|
||||
marker = tmp_path / "hang_marker"
|
||||
marker.write_text("")
|
||||
|
||||
class HangingFinishMockWandbAPI(_MockWandbAPI):
|
||||
def finish(self):
|
||||
while marker.exists():
|
||||
time.sleep(0.1)
|
||||
|
||||
logger = get_mock_wandb_logger(
|
||||
mock_api_cls=HangingFinishMockWandbAPI,
|
||||
upload_timeout=1.0,
|
||||
)
|
||||
logger.setup()
|
||||
logger.on_trial_start(0, [], trial)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
# Signalling stop will not cleanup fully due to the hanging finish
|
||||
assert logger._trial_logging_actors
|
||||
marker.unlink()
|
||||
# wandb.finish has ended -> experiment end hook should cleanup actors fully
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
assert not logger._trial_logging_actors
|
||||
|
||||
def test_wandb_kill_hanging_actor(self, trial):
|
||||
"""Test that logging actors are killed if exceeding the upload timeout
|
||||
upon experiment completion."""
|
||||
|
||||
class HangingFinishMockWandbAPI(_MockWandbAPI):
|
||||
def finish(self):
|
||||
time.sleep(5)
|
||||
|
||||
logger = get_mock_wandb_logger(
|
||||
mock_api_cls=HangingFinishMockWandbAPI,
|
||||
upload_timeout=0.1,
|
||||
)
|
||||
logger.setup()
|
||||
logger.on_trial_start(0, [], trial)
|
||||
logger.on_trial_complete(0, [], trial)
|
||||
# Signalling stop will not cleanup fully due to the hanging finish
|
||||
assert logger._trial_logging_actors
|
||||
actor = logger._trial_logging_actors[trial]
|
||||
# Experiment end hook should kill actors since upload_timeout < 5
|
||||
logger.on_experiment_end(trials=[trial])
|
||||
assert not logger._trial_logging_actors
|
||||
gc.collect()
|
||||
with pytest.raises(RayActorError):
|
||||
ray.get(actor.get_state.remote())
|
||||
|
||||
def test_wandb_destructor(self, trial):
|
||||
"""Test that the WandbLoggerCallback destructor forcefully cleans up
|
||||
logging actors."""
|
||||
|
||||
class SlowFinishMockWandbAPI(_MockWandbAPI):
|
||||
def finish(self):
|
||||
time.sleep(5)
|
||||
|
||||
logger = get_mock_wandb_logger(
|
||||
mock_api_cls=SlowFinishMockWandbAPI,
|
||||
upload_timeout=1.0,
|
||||
)
|
||||
|
||||
logger.setup()
|
||||
# Triggers logging actor run loop
|
||||
logger.on_trial_start(0, [], trial)
|
||||
actor = logger._trial_logging_actors[trial]
|
||||
del logger
|
||||
gc.collect()
|
||||
with pytest.raises(RayActorError):
|
||||
ray.get(actor.get_state.remote())
|
||||
|
||||
def test_wandb_logging_actor_fault_tolerance(self, trial):
|
||||
"""Tests that failing wandb logging actors are restarted"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
fail_marker = Path(tempdir) / "fail_marker"
|
||||
|
||||
class _FailingWandbLoggingActor(_MockWandbLoggingActor):
|
||||
def _handle_result(self, result):
|
||||
if (
|
||||
result.get("training_iteration") == 3
|
||||
and not fail_marker.exists()
|
||||
):
|
||||
fail_marker.write_text("Ok")
|
||||
raise SystemExit
|
||||
|
||||
return super()._handle_result(result)
|
||||
|
||||
logger = WandbLoggerCallback(
|
||||
project="test_project", api_key="1234", excludes=["metric2"]
|
||||
)
|
||||
logger._logger_actor_cls = _FailingWandbLoggingActor
|
||||
logger.setup()
|
||||
logger.log_trial_start(trial)
|
||||
|
||||
actor = logger._trial_logging_actors[trial]
|
||||
queue = logger._trial_queues[trial]
|
||||
|
||||
logger.log_trial_result(1, trial, result={"training_iteration": 1})
|
||||
logger.log_trial_result(2, trial, result={"training_iteration": 2})
|
||||
logger.log_trial_result(3, trial, result={"training_iteration": 3})
|
||||
|
||||
logger.log_trial_result(4, trial, result={"training_iteration": 4})
|
||||
logger.log_trial_result(5, trial, result={"training_iteration": 5})
|
||||
|
||||
queue.put((_QueueItem.END, None))
|
||||
|
||||
# Wait for the actor's run method to complete
|
||||
ray.get(logger._trial_logging_futures[trial])
|
||||
|
||||
state = ray.get(actor.get_state.remote())
|
||||
assert [metrics["training_iteration"] for metrics in state.logs] == [4, 5]
|
||||
|
||||
def test_wandb_restart(self, trial):
|
||||
"""Test that the WandbLoggerCallback reuses actors for trial restarts."""
|
||||
|
||||
logger = WandbLoggerCallback(project="test_project", api_key="1234")
|
||||
logger._logger_actor_cls = _MockWandbLoggingActor
|
||||
logger.setup()
|
||||
|
||||
assert len(logger._trial_logging_futures) == 0
|
||||
assert len(logger._logging_future_to_trial) == 0
|
||||
|
||||
logger.log_trial_start(trial)
|
||||
|
||||
assert len(logger._trial_logging_futures) == 1
|
||||
assert len(logger._logging_future_to_trial) == 1
|
||||
|
||||
logger.log_trial_start(trial)
|
||||
|
||||
assert len(logger._trial_logging_futures) == 1
|
||||
assert len(logger._logging_future_to_trial) == 1
|
||||
|
||||
|
||||
def test_wandb_logging_process_run_info_hook(monkeypatch):
|
||||
"""
|
||||
Test WANDB_PROCESS_RUN_INFO_HOOK in _WandbLoggingActor is
|
||||
correctly called by calling _WandbLoggingActor.run() mocking
|
||||
out calls to wandb.
|
||||
"""
|
||||
mock_queue = Mock(get=Mock(return_value=(_QueueItem.END, None)))
|
||||
monkeypatch.setenv(
|
||||
"WANDB_PROCESS_RUN_INFO_HOOK", "mock_wandb_process_run_info_hook"
|
||||
)
|
||||
|
||||
with patch.object(ray.air.integrations.wandb, "load_class") as mock_load_class:
|
||||
logging_process = _WandbLoggingActor(
|
||||
logdir="/tmp", queue=mock_queue, exclude=[], to_config=[]
|
||||
)
|
||||
logging_process._wandb = Mock()
|
||||
logging_process.run()
|
||||
|
||||
logging_process._wandb.init.assert_called_once()
|
||||
run = logging_process._wandb.init.return_value
|
||||
mock_load_class.assert_called_once_with("mock_wandb_process_run_info_hook")
|
||||
external_hook = mock_load_class.return_value
|
||||
external_hook.assert_called_once_with(run)
|
||||
logging_process._wandb.finish.assert_called_once()
|
||||
|
||||
|
||||
def test_wandb_logger_rank_zero_only(trial, monkeypatch):
|
||||
"""Test that logging is disabled for non-rank-0 workers when rank_zero_only is True."""
|
||||
|
||||
monkeypatch.setenv(
|
||||
WANDB_ENV_VAR,
|
||||
"abcde",
|
||||
)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.experiment_name = "test_project"
|
||||
mock_session.trial_name = "trial_0"
|
||||
mock_session.trial_id = "trial_0"
|
||||
|
||||
# Test case 1: rank_zero_only=True, rank 0
|
||||
mock_session.world_rank = 0
|
||||
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
|
||||
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
|
||||
assert not isinstance(run, RunDisabled)
|
||||
|
||||
# Test case 2: rank_zero_only=True, non-rank-0
|
||||
mock_session.world_rank = 1
|
||||
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
|
||||
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
|
||||
assert isinstance(run, RunDisabled)
|
||||
|
||||
# Test case 3: rank_zero_only=False, any rank
|
||||
mock_session.world_rank = 1
|
||||
with patch("ray.air.integrations.wandb.get_session", return_value=mock_session):
|
||||
run = setup_wandb(project="test_project", rank_zero_only=False, _wandb=Mock())
|
||||
assert not isinstance(run, RunDisabled)
|
||||
|
||||
# Test case 4: rank_zero_only=True, no session
|
||||
with patch("ray.air.integrations.wandb.get_session", return_value=None):
|
||||
run = setup_wandb(project="test_project", rank_zero_only=True, _wandb=Mock())
|
||||
assert not isinstance(run, RunDisabled)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,223 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, Tuple
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.train import ScalingConfig
|
||||
from ray.train.constants import TRAIN_DATASET_KEY
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
# Tensorflow is not installed for Python 3.12 because of keras compatibility.
|
||||
sys.exit(0)
|
||||
else:
|
||||
import tensorflow as tf
|
||||
|
||||
from ray.air.integrations.keras import ReportCheckpointCallback
|
||||
from ray.train.tensorflow import TensorflowTrainer
|
||||
|
||||
|
||||
class TestReportCheckpointCallback:
|
||||
@pytest.fixture(name="model")
|
||||
def model_fixture(self):
|
||||
model = tf.keras.Sequential(
|
||||
[tf.keras.layers.InputLayer(input_shape=(1,)), tf.keras.layers.Dense(1)]
|
||||
)
|
||||
model.compile(
|
||||
optimizer="sgd",
|
||||
loss="mean_squared_error",
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
return model
|
||||
|
||||
@patch("ray.train.report")
|
||||
@pytest.mark.parametrize(
|
||||
"metrics, expected_metrics_keys",
|
||||
[
|
||||
(None, {"loss", "accuracy", "val_loss", "val_accuracy"}),
|
||||
("loss", {"loss"}),
|
||||
(["loss", "accuracy"], {"loss", "accuracy"}),
|
||||
({"spam": "loss"}, {"spam"}),
|
||||
],
|
||||
)
|
||||
def test_reported_metrics_contain_expected_keys(
|
||||
self, mock_report, metrics, expected_metrics_keys, model
|
||||
):
|
||||
# Reported metrics contain different keys depending on the value passed to the
|
||||
# `metrics` parameter. This test varies the value of `metrics` and asserts that
|
||||
# the reported keys are correct.
|
||||
model.fit(
|
||||
x=np.zeros((1, 1)),
|
||||
y=np.zeros((1, 1)),
|
||||
validation_data=(np.zeros((1, 1)), np.zeros((1, 1))),
|
||||
callbacks=[ReportCheckpointCallback(metrics=metrics)],
|
||||
)
|
||||
|
||||
for (metrics,), _ in ray.train.report.call_args_list:
|
||||
assert metrics.keys() == expected_metrics_keys
|
||||
|
||||
@patch("ray.train.report")
|
||||
def test_report_with_default_arguments(self, mock_report, model):
|
||||
# This tests `ReportCheckpointCallback` with default arguments. The test
|
||||
# simulates the end of an epoch, and asserts that a metric and checkpoint are
|
||||
# reported.
|
||||
callback = ReportCheckpointCallback()
|
||||
callback.set_model(model)
|
||||
|
||||
callback.on_epoch_end(0, {"loss": 0})
|
||||
|
||||
assert len(ray.train.report.call_args_list) == 1
|
||||
metrics, checkpoint = self.parse_call(ray.train.report.call_args_list[0])
|
||||
assert metrics == {"loss": 0}
|
||||
assert checkpoint is not None
|
||||
|
||||
@patch("ray.train.report")
|
||||
def test_checkpoint_on_list(self, mock_report, model):
|
||||
# This tests `ReportCheckpointCallback` when `checkpoint_on` is a `list`. The
|
||||
# test simulates each event in `checkpoint_on`, and asserts that a checkpoint
|
||||
# is reported for each event.
|
||||
callback = ReportCheckpointCallback(
|
||||
checkpoint_on=["epoch_end", "train_batch_end"]
|
||||
)
|
||||
callback.model = model
|
||||
|
||||
callback.on_train_batch_end(0, {"loss": 0})
|
||||
callback.on_epoch_end(0, {"loss": 0})
|
||||
|
||||
assert len(ray.train.report.call_args_list) == 2
|
||||
_, first_checkpoint = self.parse_call(ray.train.report.call_args_list[0])
|
||||
assert first_checkpoint is not None
|
||||
_, second_checkpoint = self.parse_call(ray.train.report.call_args_list[0])
|
||||
assert second_checkpoint is not None
|
||||
|
||||
@patch("ray.train.report")
|
||||
def test_report_metrics_on_list(self, mock_report, model):
|
||||
# This tests `ReportCheckpointCallback` when `report_metrics_on` is a `list`.
|
||||
# The test simulates each event in `report_metrics_on`, and asserts that metrics
|
||||
# are reported for each event.
|
||||
callback = ReportCheckpointCallback(
|
||||
report_metrics_on=["epoch_end", "train_batch_end"]
|
||||
)
|
||||
callback.model = model
|
||||
|
||||
callback.on_train_batch_end(0, {"loss": 0})
|
||||
callback.on_epoch_end(0, {"loss": 1})
|
||||
|
||||
assert len(ray.train.report.call_args_list) == 2
|
||||
first_metric, _ = self.parse_call(ray.train.report.call_args_list[0])
|
||||
assert first_metric == {"loss": 0}
|
||||
second_metric, _ = self.parse_call(ray.train.report.call_args_list[1])
|
||||
assert second_metric == {"loss": 1}
|
||||
|
||||
@patch("ray.train.report")
|
||||
def test_report_and_checkpoint_on_different_events(self, mock_report, model):
|
||||
# This tests `ReportCheckpointCallback` when `report_metrics_on` and
|
||||
# `checkpoint_on` are different. The test asserts that:
|
||||
# 1. Checkpoints are reported on `checkpoint_on`
|
||||
# 2. Metrics are reported on `report_metrics_on`
|
||||
# 3. Metrics are reported with checkpoints
|
||||
callback = ReportCheckpointCallback(
|
||||
report_metrics_on="train_batch_end", checkpoint_on="epoch_end"
|
||||
)
|
||||
callback.model = model
|
||||
|
||||
callback.on_train_batch_end(0, {"loss": 0})
|
||||
callback.on_epoch_end(0, {"loss": 1})
|
||||
|
||||
assert len(ray.train.report.call_args_list) == 2
|
||||
first_metric, first_checkpoint = self.parse_call(
|
||||
ray.train.report.call_args_list[0]
|
||||
)
|
||||
assert first_metric == {"loss": 0}
|
||||
assert first_checkpoint is None
|
||||
second_metric, second_checkpoint = self.parse_call(
|
||||
ray.train.report.call_args_list[1]
|
||||
)
|
||||
# We should always include metrics, even if it isn't during one of the events
|
||||
# specified in `report_metrics_on`.
|
||||
assert second_metric == {"loss": 1}
|
||||
assert second_checkpoint is not None
|
||||
|
||||
@patch("ray.train.report")
|
||||
def test_report_delete_tempdir(self, mock_report, model):
|
||||
# This tests `ReportCheckpointCallback`. The test simulates the end of an epoch,
|
||||
# and asserts that the temporary checkpoint directory is deleted afterwards.
|
||||
callback = ReportCheckpointCallback()
|
||||
callback.model = model
|
||||
|
||||
callback.on_epoch_end(0, {"loss": 0})
|
||||
|
||||
assert len(ray.train.report.call_args_list) == 1
|
||||
metrics, checkpoint = self.parse_call(ray.train.report.call_args_list[0])
|
||||
assert metrics == {"loss": 0}
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.path is not None
|
||||
assert not os.path.exists(checkpoint.path)
|
||||
|
||||
def parse_call(self, call) -> Tuple[Dict, train.Checkpoint]:
|
||||
(metrics,), kwargs = call
|
||||
checkpoint = kwargs["checkpoint"]
|
||||
return metrics, checkpoint
|
||||
|
||||
|
||||
def get_dataset(a=5, b=10, size=1000):
|
||||
items = [i / size for i in range(size)]
|
||||
dataset = ray.data.from_items([{"x": x, "y": a * x + b} for x in items])
|
||||
return dataset
|
||||
|
||||
|
||||
def build_model() -> tf.keras.Model:
|
||||
model = tf.keras.Sequential(
|
||||
[
|
||||
tf.keras.layers.InputLayer(input_shape=()),
|
||||
# Add feature dimension, expanding (batch_size,) to (batch_size, 1).
|
||||
tf.keras.layers.Flatten(),
|
||||
tf.keras.layers.Dense(10),
|
||||
tf.keras.layers.Dense(1),
|
||||
]
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def train_func(config: dict):
|
||||
strategy = tf.distribute.MultiWorkerMirroredStrategy()
|
||||
with strategy.scope():
|
||||
# Model building/compiling need to be within `strategy.scope()`.
|
||||
multi_worker_model = build_model()
|
||||
multi_worker_model.compile(
|
||||
optimizer=tf.keras.optimizers.SGD(learning_rate=config.get("lr", 1e-3)),
|
||||
loss=tf.keras.losses.mean_squared_error,
|
||||
metrics=[tf.keras.metrics.mean_squared_error],
|
||||
)
|
||||
|
||||
dataset = train.get_dataset_shard("train")
|
||||
|
||||
for _ in range(config.get("epoch", 3)):
|
||||
tf_dataset = dataset.to_tf("x", "y", batch_size=32)
|
||||
multi_worker_model.fit(tf_dataset, callbacks=[ReportCheckpointCallback()])
|
||||
|
||||
|
||||
def test_keras_callback_e2e():
|
||||
epochs = 3
|
||||
config = {
|
||||
"epochs": epochs,
|
||||
}
|
||||
trainer = TensorflowTrainer(
|
||||
train_loop_per_worker=train_func,
|
||||
train_loop_config=config,
|
||||
scaling_config=ScalingConfig(num_workers=2),
|
||||
datasets={TRAIN_DATASET_KEY: get_dataset()},
|
||||
)
|
||||
trainer.fit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,394 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray import train
|
||||
from ray.data import DataIterator
|
||||
from ray.data._internal.execution.interfaces.execution_options import (
|
||||
ExecutionOptions,
|
||||
ExecutionResources,
|
||||
)
|
||||
from ray.tests.conftest import * # noqa
|
||||
from ray.train import DataConfig, ScalingConfig
|
||||
from ray.train.data_parallel_trainer import DataParallelTrainer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_4_cpus():
|
||||
address_info = ray.init(num_cpus=4)
|
||||
yield address_info
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
class TestBasic(DataParallelTrainer):
|
||||
def __init__(
|
||||
self, num_workers: int, expect_ds: bool, expect_sizes: Optional[dict], **kwargs
|
||||
):
|
||||
def train_loop_per_worker():
|
||||
data_shard = train.get_dataset_shard("train")
|
||||
assert isinstance(data_shard, DataIterator), data_shard
|
||||
for k, v in expect_sizes.items():
|
||||
shard = train.get_dataset_shard(k)
|
||||
if v == -1:
|
||||
assert shard is None, shard
|
||||
else:
|
||||
count = 0
|
||||
for batch in shard.iter_batches():
|
||||
for arr in batch.values():
|
||||
count += arr.size
|
||||
assert count == v, shard
|
||||
|
||||
kwargs.pop("scaling_config", None)
|
||||
super().__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_basic(ray_start_4_cpus):
|
||||
ds = ray.data.range(10)
|
||||
|
||||
# Single worker basic case.
|
||||
test = TestBasic(
|
||||
1,
|
||||
True,
|
||||
{"train": 10, "test": 10},
|
||||
datasets={"train": ds, "test": ds},
|
||||
)
|
||||
test.fit()
|
||||
|
||||
# Single worker, no test ds.
|
||||
test = TestBasic(1, True, {"train": 10, "test": -1}, datasets={"train": ds})
|
||||
test.fit()
|
||||
|
||||
# Two workers, train and test split.
|
||||
test = TestBasic(
|
||||
2, True, {"train": 5, "test": 5}, datasets={"train": ds, "test": ds}
|
||||
)
|
||||
test.fit()
|
||||
|
||||
# Two workers, both split.
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 5, "test": 5},
|
||||
dataset_config=DataConfig(datasets_to_split=["train", "test"]),
|
||||
datasets={"train": ds, "test": ds},
|
||||
)
|
||||
# Test get config.
|
||||
assert isinstance(test.get_dataset_config(), DataConfig)
|
||||
test.fit()
|
||||
|
||||
|
||||
def test_split(ray_start_4_cpus):
|
||||
ds = ray.data.range(10)
|
||||
|
||||
# Split all by default
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 5, "test": 5, "val": 5},
|
||||
datasets={"train": ds, "test": ds, "val": ds},
|
||||
)
|
||||
test.fit()
|
||||
|
||||
# Test flag "all"
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 5, "test": 5},
|
||||
datasets={"train": ds, "test": ds},
|
||||
dataset_config=DataConfig(datasets_to_split="all"),
|
||||
)
|
||||
|
||||
# Test split train only.
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 5, "test": 10},
|
||||
datasets={"train": ds, "test": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=["train"]),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
# Test invalid arguments
|
||||
for datasets_to_split in ["train", ("train"), {}]:
|
||||
with pytest.raises(TypeError, match="`datasets_to_split` should be.*"):
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 5, "test": 10},
|
||||
datasets={"train": ds, "test": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=datasets_to_split),
|
||||
)
|
||||
|
||||
# Test empty `datasets_to_split` list
|
||||
test = TestBasic(
|
||||
2,
|
||||
True,
|
||||
{"train": 10, "test": 10},
|
||||
datasets={"train": ds, "test": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=[]),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
|
||||
def test_configure_execution_options_carryover_context(ray_start_4_cpus):
|
||||
"""Tests that execution options in DataContext are carried over to DatConfig
|
||||
automatically."""
|
||||
|
||||
ctx = ray.data.DataContext.get_current()
|
||||
ctx.execution_options.preserve_order = True
|
||||
ctx.execution_options.verbose_progress = True
|
||||
|
||||
data_config = DataConfig()
|
||||
|
||||
ingest_options = data_config.default_ingest_options()
|
||||
assert ingest_options.preserve_order is True
|
||||
assert ingest_options.verbose_progress is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_locality", [True, False])
|
||||
def test_configure_locality(enable_locality):
|
||||
data_config = DataConfig(enable_shard_locality=enable_locality)
|
||||
|
||||
mock_ds = MagicMock()
|
||||
mock_ds.streaming_split = MagicMock()
|
||||
mock_ds.copy = MagicMock(return_value=mock_ds)
|
||||
world_size = 2
|
||||
worker_handles = [MagicMock() for _ in range(world_size)]
|
||||
worker_node_ids = ["node" + str(i) for i in range(world_size)]
|
||||
data_config.configure(
|
||||
datasets={"train": mock_ds},
|
||||
world_size=world_size,
|
||||
worker_handles=worker_handles,
|
||||
worker_node_ids=worker_node_ids,
|
||||
)
|
||||
mock_ds.streaming_split.assert_called_once()
|
||||
mock_ds.streaming_split.assert_called_with(
|
||||
world_size,
|
||||
equal=True,
|
||||
locality_hints=worker_node_ids if enable_locality else None,
|
||||
)
|
||||
|
||||
|
||||
class CustomConfig(DataConfig):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def configure(self, *args, **kwargs):
|
||||
ds = ray.data.range(10)
|
||||
return [
|
||||
{"train": ds.iterator()},
|
||||
{"train": ds.iterator()},
|
||||
]
|
||||
|
||||
|
||||
def test_custom_config_subclass(ray_start_4_cpus):
|
||||
test = TestBasic(
|
||||
1,
|
||||
True,
|
||||
{"train": 10},
|
||||
dataset_config=CustomConfig(),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
|
||||
class TestRandom(DataParallelTrainer):
|
||||
def __init__(self, num_workers: int, expect_random: bool, **kwargs):
|
||||
def train_loop_per_worker():
|
||||
data_shard = train.get_dataset_shard("train")
|
||||
assert isinstance(data_shard, DataIterator), data_shard
|
||||
epoch1 = list(data_shard.iter_rows())
|
||||
epoch2 = list(data_shard.iter_rows())
|
||||
print("Epochs", epoch1, "\n", epoch2)
|
||||
if expect_random:
|
||||
assert epoch1 != epoch2
|
||||
else:
|
||||
assert epoch1 == epoch2
|
||||
|
||||
kwargs.pop("scaling_config", None)
|
||||
super().__init__(
|
||||
train_loop_per_worker=train_loop_per_worker,
|
||||
scaling_config=ScalingConfig(num_workers=num_workers),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_per_epoch_preprocessing(ray_start_4_cpus):
|
||||
ds = ray.data.range(100, override_num_blocks=100).randomize_block_order()
|
||||
test = TestRandom(2, True, datasets={"train": ds})
|
||||
test.fit()
|
||||
|
||||
ds = ray.data.range(100, override_num_blocks=100).random_shuffle()
|
||||
test = TestRandom(2, True, datasets={"train": ds})
|
||||
test.fit()
|
||||
|
||||
ds = ray.data.range(100, override_num_blocks=100).map(
|
||||
lambda x: {"id": x["id"] * random.random()}
|
||||
)
|
||||
test = TestRandom(2, True, datasets={"train": ds})
|
||||
test.fit()
|
||||
|
||||
|
||||
def test_materialized_preprocessing(ray_start_4_cpus):
|
||||
# TODO(ekl) we should test all these configs with splitting enabled, but this
|
||||
# requires implementing deterministic streaming split.
|
||||
ds = ray.data.range(100, override_num_blocks=100).randomize_block_order()
|
||||
ds = ds.materialize()
|
||||
test = TestRandom(
|
||||
2,
|
||||
False,
|
||||
datasets={"train": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=[]),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
ds = ray.data.range(100, override_num_blocks=100).random_shuffle()
|
||||
ds = ds.materialize()
|
||||
test = TestRandom(
|
||||
2,
|
||||
False,
|
||||
datasets={"train": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=[]),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
ds = ray.data.range(100, override_num_blocks=100).map(
|
||||
lambda x: {"id": x["id"] * random.random()}
|
||||
)
|
||||
ds = ds.materialize()
|
||||
test = TestRandom(
|
||||
2,
|
||||
False,
|
||||
datasets={"train": ds},
|
||||
dataset_config=DataConfig(datasets_to_split=[]),
|
||||
)
|
||||
test.fit()
|
||||
|
||||
|
||||
def _run_data_config_resource_test(data_config):
|
||||
cluster_cpus, cluster_gpus = 20, 10
|
||||
num_workers = 2
|
||||
# Resources used by training workers.
|
||||
cpus_per_worker, gpus_per_worker = 2, 1
|
||||
|
||||
original_execution_options = data_config._get_execution_options("train")
|
||||
|
||||
ray.init(num_cpus=cluster_cpus, num_gpus=cluster_gpus)
|
||||
|
||||
class MyTrainer(DataParallelTrainer):
|
||||
def __init__(self, **kwargs):
|
||||
def train_loop_fn():
|
||||
train_ds = train.get_dataset_shard("train")
|
||||
new_execution_options = train_ds.get_context().execution_options
|
||||
if original_execution_options.is_resource_limits_default():
|
||||
# If the original resource limits are default, the new resource
|
||||
# limits should be the default as well.
|
||||
assert new_execution_options.is_resource_limits_default()
|
||||
exclude_resources = new_execution_options.exclude_resources
|
||||
assert (
|
||||
exclude_resources.cpu
|
||||
== original_execution_options.exclude_resources.cpu
|
||||
+ cpus_per_worker * num_workers
|
||||
+ 1 # trainer coordinator
|
||||
)
|
||||
assert (
|
||||
exclude_resources.gpu
|
||||
== original_execution_options.exclude_resources.gpu
|
||||
+ gpus_per_worker * num_workers
|
||||
)
|
||||
else:
|
||||
# If the original resource limits are not default, the new resource
|
||||
# limits should be the same as the original ones.
|
||||
# And the new exclude_resources should be zero.
|
||||
assert (
|
||||
new_execution_options.resource_limits
|
||||
== original_execution_options.resource_limits
|
||||
)
|
||||
assert (
|
||||
new_execution_options.exclude_resources
|
||||
== ExecutionResources.zero()
|
||||
)
|
||||
|
||||
kwargs.pop("scaling_config", None)
|
||||
|
||||
super().__init__(
|
||||
train_loop_per_worker=train_loop_fn,
|
||||
scaling_config=ScalingConfig(
|
||||
num_workers=num_workers,
|
||||
use_gpu=True,
|
||||
resources_per_worker={
|
||||
"CPU": cpus_per_worker,
|
||||
"GPU": gpus_per_worker,
|
||||
},
|
||||
),
|
||||
datasets={"train": ray.data.range(10)},
|
||||
dataset_config=data_config,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
trainer = MyTrainer()
|
||||
trainer.fit()
|
||||
|
||||
|
||||
def test_data_config_default_resource_limits(shutdown_only):
|
||||
"""Test that DataConfig preserves user-configured exclude_resources."""
|
||||
execution_options = ExecutionOptions()
|
||||
execution_options.exclude_resources = execution_options.exclude_resources.copy(
|
||||
cpu=2, gpu=1
|
||||
)
|
||||
data_config = DataConfig(execution_options=execution_options)
|
||||
|
||||
_run_data_config_resource_test(data_config)
|
||||
|
||||
|
||||
def test_data_config_manual_resource_limits(shutdown_only):
|
||||
"""Test manually setting resource limits in DataConfig."""
|
||||
execution_options = ExecutionOptions()
|
||||
execution_options.resource_limits = execution_options.resource_limits.copy(
|
||||
cpu=10, gpu=5
|
||||
)
|
||||
data_config = DataConfig(execution_options=execution_options)
|
||||
|
||||
_run_data_config_resource_test(data_config)
|
||||
|
||||
|
||||
def test_v1_train_with_v2_data_autoscaler_sets_exclude_resources(
|
||||
shutdown_only, monkeypatch
|
||||
):
|
||||
"""Regression test for the Train V1 + V2 cluster autoscaler combination."""
|
||||
monkeypatch.setenv("RAY_DATA_CLUSTER_AUTOSCALER", "V2")
|
||||
|
||||
ray.init(num_cpus=10, num_gpus=2)
|
||||
|
||||
num_train_cpus, num_train_gpus = 4.0, 2.0
|
||||
data_config = DataConfig()
|
||||
data_config.set_train_total_resources(
|
||||
num_train_cpus=num_train_cpus, num_train_gpus=num_train_gpus
|
||||
)
|
||||
|
||||
iterators = data_config.configure(
|
||||
datasets={"train": ray.data.range(10)},
|
||||
world_size=2,
|
||||
worker_handles=None,
|
||||
worker_node_ids=None,
|
||||
)
|
||||
|
||||
exclude_resources = (
|
||||
iterators[0]["train"].get_context().execution_options.exclude_resources
|
||||
)
|
||||
assert exclude_resources.cpu == num_train_cpus
|
||||
assert exclude_resources.gpu == num_train_gpus
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Test remote_storage in a ci environment with real hdfs setup."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.train.v2._internal.execution.storage import (
|
||||
_list_at_fs_path,
|
||||
_upload_to_fs_path,
|
||||
get_fs_and_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_hdfs():
|
||||
"""Set env vars required by pyarrow to talk to hdfs correctly.
|
||||
|
||||
Returns hostname and port needed for the hdfs uri."""
|
||||
|
||||
# the following file is written in `install-hdfs.sh`.
|
||||
with open("/tmp/hdfs_env", "r") as f:
|
||||
for line in f.readlines():
|
||||
line = line.rstrip("\n")
|
||||
tokens = line.split("=", maxsplit=1)
|
||||
os.environ[tokens[0]] = tokens[1]
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.environ["HADOOP_HOME"], "bin"))
|
||||
hostname = os.getenv("CONTAINER_ID")
|
||||
port = os.getenv("HDFS_PORT")
|
||||
yield hostname, port
|
||||
|
||||
|
||||
def test_hdfs(tmp_path, setup_hdfs):
|
||||
pytest.skip("TODO: Fix this test")
|
||||
|
||||
hostname, port = setup_hdfs
|
||||
hdfs_uri = f"hdfs://{hostname}:{port}/test/"
|
||||
fs, path = get_fs_and_path(hdfs_uri)
|
||||
|
||||
dummy_file = tmp_path.joinpath("dummy.txt")
|
||||
dummy_file.write_text("dummy")
|
||||
_upload_to_fs_path(dummy_file, fs, path)
|
||||
assert _list_at_fs_path(fs, path) == ["dummy.txt"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,102 @@
|
||||
import pytest
|
||||
from tblib import pickling_support
|
||||
|
||||
import ray
|
||||
from ray import cloudpickle
|
||||
from ray.air._internal.util import StartTraceback, exception_cause, skip_exceptions
|
||||
from ray.tune import Tuner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ray_start_2_cpus():
|
||||
address_info = ray.init(num_cpus=2)
|
||||
yield address_info
|
||||
# The code after the yield will run as teardown code.
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def _failing_recursive(levels: int = 0, start_traceback: int = -1):
|
||||
if levels > 0:
|
||||
if start_traceback == 0:
|
||||
try:
|
||||
_failing_recursive(
|
||||
levels=levels - 1, start_traceback=start_traceback - 1
|
||||
)
|
||||
except Exception as e:
|
||||
raise StartTraceback from e
|
||||
else:
|
||||
_failing_recursive(levels=levels - 1, start_traceback=start_traceback - 1)
|
||||
else:
|
||||
raise RuntimeError("Failing")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("levels", [4, 5, 6, 7, 8, 9, 10])
|
||||
def test_short_traceback(levels):
|
||||
start_traceback = 3
|
||||
with pytest.raises(StartTraceback) as exc_info:
|
||||
_failing_recursive(levels=levels, start_traceback=start_traceback)
|
||||
|
||||
exc = skip_exceptions(exc_info.value)
|
||||
tb = exc.__traceback__
|
||||
i = 0
|
||||
while tb:
|
||||
i += 1
|
||||
tb = tb.tb_next
|
||||
|
||||
assert i == levels - start_traceback + 1
|
||||
|
||||
|
||||
def test_recursion():
|
||||
"""Test that the skipped exception does not point to the original exception."""
|
||||
root_exception = None
|
||||
|
||||
with pytest.raises(StartTraceback) as exc_info:
|
||||
try:
|
||||
raise Exception("Root Exception")
|
||||
except Exception as e:
|
||||
root_exception = e
|
||||
raise StartTraceback from root_exception
|
||||
|
||||
assert root_exception, "Root exception was not captured."
|
||||
|
||||
start_traceback = exc_info.value
|
||||
skipped_exception = skip_exceptions(start_traceback)
|
||||
|
||||
assert (
|
||||
root_exception != skipped_exception
|
||||
), "Skipped exception points to the original exception."
|
||||
|
||||
|
||||
def test_tblib():
|
||||
"""Test that tblib does not cause a maximum recursion error."""
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
try:
|
||||
try:
|
||||
raise Exception("Root Exception")
|
||||
except Exception as root_exception:
|
||||
raise StartTraceback from root_exception
|
||||
except Exception as start_traceback:
|
||||
raise skip_exceptions(start_traceback) from exception_cause(start_traceback)
|
||||
|
||||
pickling_support.install()
|
||||
reraised_exception = exc_info.value
|
||||
# This should not raise a RecursionError/PicklingError.
|
||||
cloudpickle.dumps(reraised_exception)
|
||||
|
||||
|
||||
def test_traceback_tuner(ray_start_2_cpus):
|
||||
"""Ensure that the Tuner's stack trace is not too long."""
|
||||
|
||||
def failing(config):
|
||||
raise RuntimeError("Error")
|
||||
|
||||
tuner = Tuner(failing)
|
||||
results = tuner.fit()
|
||||
assert len(str(results[0].error).split("\n")) <= 20
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", "-x", __file__]))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Test AIR internal utilities (under ray.air._internal)."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.air._internal.filelock import RAY_LOCKFILE_DIR, TempFileLock
|
||||
|
||||
|
||||
def test_temp_file_lock(tmp_path, monkeypatch):
|
||||
"""Test that the directory where temp file locks are saved can be configured
|
||||
via the env variable that configures the global Ray temp dir."""
|
||||
monkeypatch.setenv("RAY_TMPDIR", str(tmp_path))
|
||||
assert str(tmp_path) in ray._common.utils.get_default_system_temp_dir()
|
||||
with TempFileLock(path="abc.txt"):
|
||||
assert RAY_LOCKFILE_DIR in os.listdir(tmp_path)
|
||||
assert os.listdir(tmp_path / RAY_LOCKFILE_DIR)
|
||||
|
||||
|
||||
def test_multiple_file_locks(tmp_path, monkeypatch):
|
||||
"""Test that a new file lock is created for unique paths."""
|
||||
monkeypatch.setenv("RAY_TMPDIR", str(tmp_path))
|
||||
with TempFileLock(path="abc.txt"):
|
||||
with TempFileLock(path="subdir/abc.txt"):
|
||||
assert RAY_LOCKFILE_DIR in os.listdir(tmp_path)
|
||||
# We should have 2 locks, one for abc.txt and one for subdir/abc.txt
|
||||
assert len(os.listdir(tmp_path / RAY_LOCKFILE_DIR)) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,348 @@
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.air.constants import TENSOR_COLUMN_NAME
|
||||
from ray.air.data_batch_type import DataBatchType
|
||||
from ray.data.util.expression_utils import _get_setting_with_copy_warning
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pandas as pd
|
||||
|
||||
# TODO: Consolidate data conversion edges for arrow bug workaround.
|
||||
try:
|
||||
import pyarrow
|
||||
except ImportError:
|
||||
pyarrow = None
|
||||
|
||||
# Lazy import to avoid ray init failures without pandas installed and allow
|
||||
# dataset to import modules in this file.
|
||||
_pandas = None
|
||||
|
||||
|
||||
def _lazy_import_pandas():
|
||||
global _pandas
|
||||
if _pandas is None:
|
||||
import pandas
|
||||
|
||||
_pandas = pandas
|
||||
return _pandas
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BatchFormat(str, Enum):
|
||||
PANDAS = "pandas"
|
||||
# TODO: Remove once Arrow is deprecated as user facing batch format
|
||||
ARROW = "arrow"
|
||||
NUMPY = "numpy" # Either a single numpy array or a Dict of numpy arrays.
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BlockFormat(str, Enum):
|
||||
"""Internal Dataset block format enum."""
|
||||
|
||||
PANDAS = "pandas"
|
||||
ARROW = "arrow"
|
||||
SIMPLE = "simple"
|
||||
|
||||
|
||||
def _convert_batch_type_to_pandas(
|
||||
data: DataBatchType,
|
||||
cast_tensor_columns: bool = False,
|
||||
) -> "pd.DataFrame":
|
||||
"""Convert the provided data to a Pandas DataFrame.
|
||||
|
||||
Args:
|
||||
data: Data of type DataBatchType
|
||||
cast_tensor_columns: Whether tensor columns should be cast to NumPy ndarrays.
|
||||
|
||||
Returns:
|
||||
A pandas Dataframe representation of the input data.
|
||||
|
||||
"""
|
||||
pd = _lazy_import_pandas()
|
||||
|
||||
if isinstance(data, np.ndarray):
|
||||
data = pd.DataFrame({TENSOR_COLUMN_NAME: _ndarray_to_column(data)})
|
||||
elif isinstance(data, dict):
|
||||
tensor_dict = {}
|
||||
for col_name, col in data.items():
|
||||
if not isinstance(col, np.ndarray):
|
||||
raise ValueError(
|
||||
"All values in the provided dict must be of type "
|
||||
f"np.ndarray. Found type {type(col)} for key {col_name} "
|
||||
f"instead."
|
||||
)
|
||||
tensor_dict[col_name] = _ndarray_to_column(col)
|
||||
data = pd.DataFrame(tensor_dict)
|
||||
elif pyarrow is not None and isinstance(data, pyarrow.Table):
|
||||
data = data.to_pandas()
|
||||
elif not isinstance(data, pd.DataFrame):
|
||||
raise ValueError(
|
||||
f"Received data of type: {type(data)}, but expected it to be one "
|
||||
f"of {DataBatchType}"
|
||||
)
|
||||
if cast_tensor_columns:
|
||||
data = _cast_tensor_columns_to_ndarrays(data)
|
||||
return data
|
||||
|
||||
|
||||
def _convert_pandas_to_batch_type(
|
||||
data: "pd.DataFrame",
|
||||
type: BatchFormat,
|
||||
cast_tensor_columns: bool = False,
|
||||
) -> DataBatchType:
|
||||
"""Convert the provided Pandas dataframe to the provided ``type``.
|
||||
|
||||
Args:
|
||||
data: A Pandas DataFrame
|
||||
type: The specific ``BatchFormat`` to convert to.
|
||||
cast_tensor_columns: Whether tensor columns should be cast to our tensor
|
||||
extension type.
|
||||
|
||||
Returns:
|
||||
The input data represented with the provided type.
|
||||
"""
|
||||
if cast_tensor_columns:
|
||||
data = _cast_ndarray_columns_to_tensor_extension(data)
|
||||
if type == BatchFormat.PANDAS:
|
||||
return data
|
||||
|
||||
elif type == BatchFormat.NUMPY:
|
||||
if len(data.columns) == 1:
|
||||
# If just a single column, return as a single numpy array.
|
||||
return data.iloc[:, 0].to_numpy()
|
||||
else:
|
||||
# Else return as a dict of numpy arrays.
|
||||
output_dict = {}
|
||||
for column in data:
|
||||
output_dict[column] = data[column].to_numpy()
|
||||
return output_dict
|
||||
|
||||
elif type == BatchFormat.ARROW:
|
||||
if not pyarrow:
|
||||
raise ValueError(
|
||||
"Attempted to convert data to Pyarrow Table but Pyarrow "
|
||||
"is not installed. Please do `pip install pyarrow` to "
|
||||
"install Pyarrow."
|
||||
)
|
||||
return pyarrow.Table.from_pandas(data)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Received type {type}, but expected it to be one of {DataBatchType}"
|
||||
)
|
||||
|
||||
|
||||
@Deprecated
|
||||
def convert_batch_type_to_pandas(
|
||||
data: DataBatchType,
|
||||
cast_tensor_columns: bool = False,
|
||||
):
|
||||
"""Convert the provided data to a Pandas DataFrame.
|
||||
|
||||
This API is deprecated from Ray 2.4.
|
||||
|
||||
Args:
|
||||
data: Data of type DataBatchType
|
||||
cast_tensor_columns: Whether tensor columns should be cast to NumPy ndarrays.
|
||||
|
||||
Returns:
|
||||
A pandas Dataframe representation of the input data.
|
||||
|
||||
"""
|
||||
warnings.warn(
|
||||
"`convert_batch_type_to_pandas` is deprecated as a developer API "
|
||||
"starting from Ray 2.4. All batch format conversions should be "
|
||||
"done manually instead of relying on this API.",
|
||||
PendingDeprecationWarning,
|
||||
)
|
||||
return _convert_batch_type_to_pandas(
|
||||
data=data, cast_tensor_columns=cast_tensor_columns
|
||||
)
|
||||
|
||||
|
||||
@Deprecated
|
||||
def convert_pandas_to_batch_type(
|
||||
data: "pd.DataFrame",
|
||||
type: BatchFormat,
|
||||
cast_tensor_columns: bool = False,
|
||||
):
|
||||
"""Convert the provided Pandas dataframe to the provided ``type``.
|
||||
|
||||
Args:
|
||||
data: A Pandas DataFrame
|
||||
type: The specific ``BatchFormat`` to convert to.
|
||||
cast_tensor_columns: Whether tensor columns should be cast to our tensor
|
||||
extension type.
|
||||
|
||||
Returns:
|
||||
The input data represented with the provided type.
|
||||
"""
|
||||
warnings.warn(
|
||||
"`convert_pandas_to_batch_type` is deprecated as a developer API "
|
||||
"starting from Ray 2.4. All batch format conversions should be "
|
||||
"done manually instead of relying on this API.",
|
||||
PendingDeprecationWarning,
|
||||
)
|
||||
return _convert_pandas_to_batch_type(
|
||||
data=data, type=type, cast_tensor_columns=cast_tensor_columns
|
||||
)
|
||||
|
||||
|
||||
def _convert_batch_type_to_numpy(
|
||||
data: DataBatchType,
|
||||
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
|
||||
"""Convert the provided data to a NumPy ndarray or dict of ndarrays.
|
||||
|
||||
Args:
|
||||
data: Data of type DataBatchType
|
||||
|
||||
Returns:
|
||||
A numpy representation of the input data.
|
||||
"""
|
||||
pd = _lazy_import_pandas()
|
||||
|
||||
if isinstance(data, np.ndarray):
|
||||
return data
|
||||
elif isinstance(data, dict):
|
||||
for col_name, col in data.items():
|
||||
if not isinstance(col, np.ndarray):
|
||||
raise ValueError(
|
||||
"All values in the provided dict must be of type "
|
||||
f"np.ndarray. Found type {type(col)} for key {col_name} "
|
||||
f"instead."
|
||||
)
|
||||
return data
|
||||
elif pyarrow is not None and isinstance(data, pyarrow.Table):
|
||||
from ray.data._internal.arrow_ops import transform_pyarrow
|
||||
from ray.data._internal.tensor_extensions.arrow import (
|
||||
get_arrow_extension_fixed_shape_tensor_types,
|
||||
)
|
||||
|
||||
column_values_ndarrays = []
|
||||
|
||||
for col in data.columns:
|
||||
# Combine columnar values arrays to make these contiguous
|
||||
# (making them compatible with numpy format)
|
||||
combined_array = transform_pyarrow.combine_chunked_array(col)
|
||||
|
||||
column_values_ndarrays.append(
|
||||
transform_pyarrow.to_numpy(combined_array, zero_copy_only=False)
|
||||
)
|
||||
|
||||
arrow_fixed_shape_tensor_types = get_arrow_extension_fixed_shape_tensor_types()
|
||||
|
||||
# NOTE: This branch is here for backwards-compatibility
|
||||
if data.column_names == [TENSOR_COLUMN_NAME] and (
|
||||
isinstance(data.schema.types[0], arrow_fixed_shape_tensor_types)
|
||||
):
|
||||
return column_values_ndarrays[0]
|
||||
|
||||
return dict(zip(data.column_names, column_values_ndarrays))
|
||||
elif isinstance(data, pd.DataFrame):
|
||||
return _convert_pandas_to_batch_type(data, BatchFormat.NUMPY)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Received data of type: {type(data)}, but expected it to be one "
|
||||
f"of {DataBatchType}"
|
||||
)
|
||||
|
||||
|
||||
def _ndarray_to_column(arr: np.ndarray) -> Union["pd.Series", List[np.ndarray]]:
|
||||
"""Convert a NumPy ndarray into an appropriate column format for insertion into a
|
||||
pandas DataFrame.
|
||||
|
||||
If conversion to a pandas Series fails (e.g. if the ndarray is multi-dimensional),
|
||||
fall back to a list of NumPy ndarrays.
|
||||
"""
|
||||
pd = _lazy_import_pandas()
|
||||
try:
|
||||
# Try to convert to Series, falling back to a list conversion if this fails
|
||||
# (e.g. if the ndarray is multi-dimensional).
|
||||
return pd.Series(arr)
|
||||
except ValueError:
|
||||
return list(arr)
|
||||
|
||||
|
||||
def _unwrap_ndarray_object_type_if_needed(arr: np.ndarray) -> np.ndarray:
|
||||
"""Unwrap an object-dtyped NumPy ndarray containing ndarray pointers into a single
|
||||
contiguous ndarray, if needed/possible.
|
||||
"""
|
||||
if arr.dtype.type is np.object_:
|
||||
try:
|
||||
# Try to convert the NumPy ndarray to a non-object dtype.
|
||||
arr = np.array([np.asarray(v) for v in arr])
|
||||
except Exception:
|
||||
# This may fail if the subndarrays are of heterogeneous shape
|
||||
pass
|
||||
return arr
|
||||
|
||||
|
||||
def _cast_ndarray_columns_to_tensor_extension(df: "pd.DataFrame") -> "pd.DataFrame":
|
||||
"""
|
||||
Cast all NumPy ndarray columns in df to our tensor extension type, TensorArray.
|
||||
"""
|
||||
# Get the SettingWithCopyWarning class if available
|
||||
SettingWithCopyWarning = _get_setting_with_copy_warning()
|
||||
|
||||
from ray.data._internal.tensor_extensions.pandas import (
|
||||
TensorArray,
|
||||
column_needs_tensor_extension,
|
||||
)
|
||||
|
||||
# Try to convert any ndarray columns to TensorArray columns.
|
||||
# TODO(Clark): Once Pandas supports registering extension types for type
|
||||
# inference on construction, implement as much for NumPy ndarrays and remove
|
||||
# this. See https://github.com/pandas-dev/pandas/issues/41848
|
||||
# TODO(Clark): Optimize this with propagated DataFrame metadata containing a list of
|
||||
# column names containing tensor columns, to make this an O(# of tensor columns)
|
||||
# check rather than the current O(# of columns) check.
|
||||
for col_name, col in df.items():
|
||||
if column_needs_tensor_extension(col):
|
||||
try:
|
||||
# Suppress Pandas warnings:
|
||||
# https://github.com/ray-project/ray/issues/29270
|
||||
# We actually want in-place operations so we surpress this warning.
|
||||
# https://stackoverflow.com/a/74193599
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=FutureWarning)
|
||||
if SettingWithCopyWarning is not None:
|
||||
warnings.simplefilter("ignore", category=SettingWithCopyWarning)
|
||||
df[col_name] = TensorArray(col)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Tried to cast column {col_name} to the TensorArray tensor "
|
||||
"extension type but the conversion failed. To disable "
|
||||
"automatic casting to this tensor extension, set "
|
||||
"ctx = DataContext.get_current(); "
|
||||
"ctx.enable_tensor_extension_casting = False."
|
||||
) from e
|
||||
return df
|
||||
|
||||
|
||||
def _cast_tensor_columns_to_ndarrays(df: "pd.DataFrame") -> "pd.DataFrame":
|
||||
"""Cast all tensor extension columns in df to NumPy ndarrays."""
|
||||
# Get the SettingWithCopyWarning class if available
|
||||
SettingWithCopyWarning = _get_setting_with_copy_warning()
|
||||
from ray.data._internal.tensor_extensions.pandas import TensorDtype
|
||||
|
||||
# Try to convert any tensor extension columns to ndarray columns.
|
||||
# TODO(Clark): Optimize this with propagated DataFrame metadata containing a list of
|
||||
# column names containing tensor columns, to make this an O(# of tensor columns)
|
||||
# check rather than the current O(# of columns) check.
|
||||
for col_name, col in df.items():
|
||||
if isinstance(col.dtype, TensorDtype):
|
||||
# Suppress Pandas warnings:
|
||||
# https://github.com/ray-project/ray/issues/29270
|
||||
# We actually want in-place operations so we surpress this warning.
|
||||
# https://stackoverflow.com/a/74193599
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=FutureWarning)
|
||||
if SettingWithCopyWarning is not None:
|
||||
warnings.simplefilter("ignore", category=SettingWithCopyWarning)
|
||||
df[col_name] = list(col.to_numpy())
|
||||
return df
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def _get_node_id_from_node_ip(node_ip: str) -> Optional[str]:
|
||||
"""Returns the node ID for the first alive node with the input IP."""
|
||||
for node in ray.nodes():
|
||||
if node["Alive"] and node["NodeManagerAddress"] == node_ip:
|
||||
return node["NodeID"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _force_on_node(
|
||||
node_id: str,
|
||||
remote_func_or_actor_class: Optional[
|
||||
Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass]
|
||||
] = None,
|
||||
) -> Union[Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass], Dict]:
|
||||
"""Schedule a remote function or actor class on a given node.
|
||||
|
||||
Args:
|
||||
node_id: The node to schedule on.
|
||||
remote_func_or_actor_class: A Ray remote function or actor class
|
||||
to schedule on the input node. If None, this function will directly
|
||||
return the options dict to pass to another remote function or actor class
|
||||
as remote options.
|
||||
Returns:
|
||||
The provided remote function or actor class, but with options modified to force
|
||||
placement on the input node. If remote_func_or_actor_class is None,
|
||||
the options dict to pass to another remote function or
|
||||
actor class as remote options kwargs.
|
||||
"""
|
||||
|
||||
options = {"label_selector": {ray._raylet.RAY_NODE_ID_KEY: node_id}}
|
||||
|
||||
if remote_func_or_actor_class is None:
|
||||
return options
|
||||
|
||||
return remote_func_or_actor_class.options(**options)
|
||||
|
||||
|
||||
def _force_on_current_node(
|
||||
remote_func_or_actor_class: Optional[
|
||||
Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass]
|
||||
] = None
|
||||
) -> Union[Union[ray.remote_function.RemoteFunction, ray.actor.ActorClass], Dict]:
|
||||
"""Schedule a remote function or actor class on the current node.
|
||||
|
||||
If using Ray Client, the current node is the client server node.
|
||||
|
||||
Args:
|
||||
remote_func_or_actor_class: A Ray remote function or actor class
|
||||
to schedule on the current node. If None, this function will directly
|
||||
return the options dict to pass to another remote function or actor class
|
||||
as remote options.
|
||||
Returns:
|
||||
The provided remote function or actor class, but with options modified to force
|
||||
placement on the current node. If remote_func_or_actor_class is None,
|
||||
the options dict to pass to another remote function or
|
||||
actor class as remote options kwargs.
|
||||
"""
|
||||
current_node_id = ray.get_runtime_context().get_node_id()
|
||||
return _force_on_node(current_node_id, remote_func_or_actor_class)
|
||||
@@ -0,0 +1,7 @@
|
||||
# NOTE: We provide these as aliases to maintain compatibility with older version
|
||||
# of Arrow `PyExtensionType` that relies on picked class references that
|
||||
# reference `ray.air.util.{tensor|object}_extensions.arrow.*` classes
|
||||
|
||||
from ray.data._internal.object_extensions.arrow import (
|
||||
ArrowPythonObjectType, # noqa: F401
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# NOTE: We provide these as aliases to maintain compatibility with older version
|
||||
# of Arrow `PyExtensionType` that relies on picked class references that
|
||||
# reference `ray.air.util.tensor_extensions.arrow.*` classes
|
||||
|
||||
from ray.data._internal.tensor_extensions.arrow import (
|
||||
ArrowTensorType, # noqa: F401
|
||||
ArrowTensorTypeV2, # noqa: F401
|
||||
ArrowVariableShapedTensorType, # noqa: F401
|
||||
)
|
||||
Reference in New Issue
Block a user