chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
load("//bazel:python.bzl", "doctest")
doctest(
files = glob(
["**/*.py"],
exclude = ["**/thirdparty_files/**"],
),
tags = ["team:core"],
)
filegroup(
name = "src_files",
srcs = glob(
["**/*.py"],
exclude = ["**/thirdparty_files/**"],
),
visibility = ["//:__pkg__"],
)
View File
@@ -0,0 +1,92 @@
from typing import Optional, Set
from ray._private.accelerators.accelerator import (
RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO_ENV_VAR,
AcceleratorManager,
)
from ray._private.accelerators.amd_gpu import AMDGPUAcceleratorManager
from ray._private.accelerators.furiosa import FuriosaAcceleratorManager
from ray._private.accelerators.hpu import HPUAcceleratorManager
from ray._private.accelerators.intel_gpu import IntelGPUAcceleratorManager
from ray._private.accelerators.metax_gpu import MetaxGPUAcceleratorManager
from ray._private.accelerators.neuron import NeuronAcceleratorManager
from ray._private.accelerators.npu import NPUAcceleratorManager
from ray._private.accelerators.nvidia_gpu import NvidiaGPUAcceleratorManager
from ray._private.accelerators.rbln import RBLNAcceleratorManager
from ray._private.accelerators.tpu import TPUAcceleratorManager
def get_all_accelerator_managers() -> Set[AcceleratorManager]:
"""Get all accelerator managers supported by Ray."""
return {
NvidiaGPUAcceleratorManager,
IntelGPUAcceleratorManager,
AMDGPUAcceleratorManager,
TPUAcceleratorManager,
NeuronAcceleratorManager,
HPUAcceleratorManager,
NPUAcceleratorManager,
RBLNAcceleratorManager,
MetaxGPUAcceleratorManager,
FuriosaAcceleratorManager,
}
def get_all_accelerator_resource_names() -> Set[str]:
"""Get all resource names for accelerators."""
return {
accelerator_manager.get_resource_name()
for accelerator_manager in get_all_accelerator_managers()
}
def get_accelerator_manager_for_resource(
resource_name: str,
) -> Optional[AcceleratorManager]:
"""Get the corresponding accelerator manager for the given
accelerator resource name
E.g., TPUAcceleratorManager is returned if resource name is "TPU"
"""
try:
return get_accelerator_manager_for_resource._resource_name_to_accelerator_manager.get( # noqa: E501
resource_name, None
)
except AttributeError:
# Lazy initialization.
resource_name_to_accelerator_manager = {
accelerator_manager.get_resource_name(): accelerator_manager
for accelerator_manager in get_all_accelerator_managers()
}
# Special handling for GPU resource name since multiple accelerator managers
# have the same GPU resource name.
if AMDGPUAcceleratorManager.get_current_node_num_accelerators() > 0:
resource_name_to_accelerator_manager["GPU"] = AMDGPUAcceleratorManager
elif IntelGPUAcceleratorManager.get_current_node_num_accelerators() > 0:
resource_name_to_accelerator_manager["GPU"] = IntelGPUAcceleratorManager
elif MetaxGPUAcceleratorManager.get_current_node_num_accelerators() > 0:
resource_name_to_accelerator_manager["GPU"] = MetaxGPUAcceleratorManager
else:
resource_name_to_accelerator_manager["GPU"] = NvidiaGPUAcceleratorManager
get_accelerator_manager_for_resource._resource_name_to_accelerator_manager = (
resource_name_to_accelerator_manager
)
return resource_name_to_accelerator_manager.get(resource_name, None)
__all__ = [
"NvidiaGPUAcceleratorManager",
"IntelGPUAcceleratorManager",
"AMDGPUAcceleratorManager",
"TPUAcceleratorManager",
"NeuronAcceleratorManager",
"HPUAcceleratorManager",
"NPUAcceleratorManager",
"RBLNAcceleratorManager",
"MetaxGPUAcceleratorManager",
"FuriosaAcceleratorManager",
"get_all_accelerator_managers",
"get_all_accelerator_resource_names",
"get_accelerator_manager_for_resource",
"RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO_ENV_VAR",
]
@@ -0,0 +1,158 @@
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Tuple
# https://github.com/ray-project/ray/issues/54868
# Ray no longer overrides accelerator ids environment variables when the number
# of accelerators is zero. For example, if a user sets `num_gpus=0` in
# `ray.init()`, the environment variable `CUDA_VISIBLE_DEVICES` will not be
# set to an empty string.
#
# Set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=1 to restore the old behavior where
# Ray would override accelerator env vars even when zero accelerators are assigned.
#
RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO_ENV_VAR = "RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO"
class AcceleratorManager(ABC):
"""This class contains all the functions needed for supporting
an accelerator family in Ray."""
@staticmethod
@abstractmethod
def get_resource_name() -> str:
"""Get the name of the resource representing this accelerator family.
Returns:
The resource name: e.g., the resource name for NVIDIA GPUs is "GPU"
"""
@staticmethod
@abstractmethod
def get_visible_accelerator_ids_env_var() -> str:
"""Get the env var that sets the ids of visible accelerators of this family.
Returns:
The env var for setting visible accelerator ids: e.g.,
CUDA_VISIBLE_DEVICES for NVIDIA GPUs.
"""
@staticmethod
@abstractmethod
def get_current_node_num_accelerators() -> int:
"""Get the total number of accelerators of this family on the current node.
Returns:
The detected total number of accelerators of this family.
Return 0 if the current node doesn't contain accelerators of this family.
"""
@staticmethod
@abstractmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Get the type of the accelerator of this family on the current node.
Currently Ray only supports single accelerator type of
an accelerator family on each node.
The result should only be used when get_current_node_num_accelerators() > 0.
Returns:
The detected accelerator type of this family: e.g., H100 for NVIDIA GPU.
Return None if it's unknown or the node doesn't have
accelerators of this family.
"""
@staticmethod
@abstractmethod
def get_current_node_additional_resources() -> Optional[Dict[str, float]]:
"""Get any additional resources required for the current node.
In case a particular accelerator type requires considerations for
additional resources (e.g. for TPUs, providing the TPU pod type and
TPU name), this function can be used to provide the
additional logical resources.
Returns:
A dictionary representing additional resources that may be
necessary for a particular accelerator type.
"""
@staticmethod
@abstractmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
"""Validate the resource request quantity of this accelerator resource.
Args:
quantity: The resource request quantity to be validated.
Returns:
(valid, error_message) tuple: the first element of the tuple
indicates whether the given quantity is valid or not,
the second element is the error message
if the given quantity is invalid.
"""
@staticmethod
@abstractmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
"""Get the ids of accelerators of this family that are visible to the current process.
Returns:
The list of visiable accelerator ids.
Return None if all accelerators are visible.
"""
@staticmethod
@abstractmethod
def set_current_process_visible_accelerator_ids(ids: List[str]) -> None:
"""Set the ids of accelerators of this family that are visible to the current process.
Args:
ids: The ids of visible accelerators of this family.
"""
@staticmethod
def get_ec2_instance_num_accelerators(
instance_type: str, instances: dict
) -> Optional[int]:
"""Get the number of accelerators of this family on ec2 instance with given type.
Args:
instance_type: The ec2 instance type.
instances: Map from ec2 instance type to instance metadata returned by
ec2 `describe-instance-types`.
Returns:
The number of accelerators of this family on the ec2 instance
with given type.
Return None if it's unknown.
"""
return None
@staticmethod
def get_ec2_instance_accelerator_type(
instance_type: str, instances: dict
) -> Optional[str]:
"""Get the accelerator type of this family on ec2 instance with given type.
Args:
instance_type: The ec2 instance type.
instances: Map from ec2 instance type to instance metadata returned by
ec2 `describe-instance-types`.
Returns:
The accelerator type of this family on the ec2 instance with given type.
Return None if it's unknown.
"""
return None
@staticmethod
def get_current_node_accelerator_labels() -> Optional[Dict[str, str]]:
"""Get accelerator related Ray node labels of the curent node.
Returns:
A dictionary mapping accelerator related label keys to values.
"""
return None
+153
View File
@@ -0,0 +1,153 @@
import logging
import os
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
HIP_VISIBLE_DEVICES_ENV_VAR = "HIP_VISIBLE_DEVICES"
NOSET_HIP_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES"
amd_product_dict = {
"0x66a1": "AMD-Instinct-MI50",
"0x738c": "AMD-Instinct-MI100",
"0x7408": "AMD-Instinct-MI250X",
"0x740c": "AMD-Instinct-MI250X-MI250",
"0x740f": "AMD-Instinct-MI210",
"0x74a0": "AMD-Instinct-MI300A",
"0x74a1": "AMD-Instinct-MI300X-OAM",
"0x74a2": "AMD-Instinct-MI308X-OAM",
"0x74a9": "AMD-Instinct-MI300X-HF",
"0x74a5": "AMD-Instinct-MI325X-OAM",
"0x75a0": "AMD-Instinct-MI350X-OAM",
"0x75a3": "AMD-Instinct-MI355X-OAM",
"0x6798": "AMD-Radeon-R9-200-HD-7900",
"0x6799": "AMD-Radeon-HD-7900",
"0x679A": "AMD-Radeon-HD-7900",
"0x679B": "AMD-Radeon-HD-7900",
}
class AMDGPUAcceleratorManager(AcceleratorManager):
"""AMD GPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "GPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
if (
HIP_VISIBLE_DEVICES_ENV_VAR not in os.environ
and "ROCR_VISIBLE_DEVICES" in os.environ
):
raise RuntimeError(
f"Please use {HIP_VISIBLE_DEVICES_ENV_VAR} instead of ROCR_VISIBLE_DEVICES"
)
return HIP_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
amd_visible_devices = os.environ.get(
AMDGPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if amd_visible_devices is None:
return None
if amd_visible_devices == "":
return []
if amd_visible_devices == "NoDevFiles":
return []
return list(amd_visible_devices.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
import ray._private.thirdparty.pyamdsmi as pyamdsmi
num_gpus = 0
try:
pyamdsmi.smi_initialize()
num_gpus = pyamdsmi.smi_get_device_count()
except Exception:
pass
finally:
try:
pyamdsmi.smi_shutdown()
except Exception:
pass
return num_gpus
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
try:
device_ids = AMDGPUAcceleratorManager._get_amd_device_ids()
if device_ids is None:
return None
return AMDGPUAcceleratorManager._gpu_name_to_accelerator_type(device_ids[0])
except Exception:
return None
@staticmethod
def _gpu_name_to_accelerator_type(name):
if name is None:
return None
try:
match = amd_product_dict[name]
return match
except Exception:
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_amd_devices: List[str],
) -> None:
if env_bool(NOSET_HIP_VISIBLE_DEVICES_ENV_VAR, False):
return
os.environ[
AMDGPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_amd_devices])
@staticmethod
def _get_amd_device_ids() -> List[str]:
"""Get the list of GPUs IDs
Example:
On a node with 2x MI210 GPUs
pyamdsmi library python bindings
return: ['0x740f', '0x740f']
Returns:
A list of strings containing GPU IDs
"""
import ray._private.thirdparty.pyamdsmi as pyamdsmi
device_ids = []
try:
pyamdsmi.smi_initialize()
num_devices = pyamdsmi.smi_get_device_count()
for i in range(num_devices):
did = pyamdsmi.smi_get_device_id(i)
if did >= 0:
device_ids.append(hex(did))
except Exception:
return None
finally:
try:
pyamdsmi.pyamdsmi_shutdown()
except Exception:
pass
return device_ids
+209
View File
@@ -0,0 +1,209 @@
import logging
import os
from functools import lru_cache
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
# Ray uses ``FURIOSA_DEVICES`` to track which Furiosa NPUs are assigned to a
# worker/actor process. The value uses ``npu:<index>`` notation. Ray's
# scheduler operates at the device level, so the value Ray writes is always
# the device-level form (e.g. ``npu:0,npu:3``). Bare integer IDs are also
# accepted on read for convenience.
#
# Note that ``furiosa-llm``'s Python API does not honor ``FURIOSA_DEVICES``
# automatically; callers must pass ``devices=os.environ["FURIOSA_DEVICES"]``
# (or an equivalent list) explicitly to ``furiosa_llm.LLM(...)``. The
# ``furiosa-llm`` CLI does read the value but accepts a richer
# ``npu:X:Y`` (PE-level) form that Ray does not currently preserve through
# worker scheduling; see ``_strip_npu_prefix`` below.
FURIOSA_VISIBLE_DEVICES_ENV_VAR = "FURIOSA_DEVICES"
NOSET_FURIOSA_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_FURIOSA_DEVICES"
_FURIOSA_DEVICE_PREFIX = "npu:"
@lru_cache(maxsize=None)
def _ensure_furiosa_initialized() -> bool:
"""Run ``furiosa_smi_py.init()`` exactly once per process."""
from furiosa_smi_py import init
init()
return True
def _get_furiosa_list_devices():
"""Lazy import + one-shot init of ``furiosa_smi_py``.
Returns the current ``list_devices`` callable on success, or ``None`` if
the SDK is unavailable or initialization fails. ``list_devices`` is
re-imported on each call so test monkeypatches on the module attribute
take effect.
"""
try:
_ensure_furiosa_initialized()
from furiosa_smi_py import list_devices
except Exception as e:
logger.debug("furiosa_smi_py is unavailable: %s", e)
return None
return list_devices
def _strip_npu_prefix(token: str) -> str:
"""Return the numeric device index from an ``npu:<id>`` token.
Accepts bare integers (``"3"``) as well as the prefixed form
(``"npu:3"``) so that values written by other tooling round-trip
cleanly.
"""
token = token.strip()
if token.startswith(_FURIOSA_DEVICE_PREFIX):
token = token[len(_FURIOSA_DEVICE_PREFIX) :]
# ``furiosa-llm`` accepts both ``npu:X`` (whole NPU) and ``npu:X:Y``
# (PE-level, e.g. ``npu:0:0-3`` for fused PE 0-3 of NPU 0). Ray's
# scheduler currently operates at the device level only, so we keep
# the device index and drop any trailing PE selector. Round-tripping
# PE-level partitioning through worker scheduling is tracked as a
# follow-up enhancement.
return token.split(":", 1)[0]
class FuriosaAcceleratorManager(AcceleratorManager):
"""FuriosaAI NPU accelerators.
Resource name is ``FURIOSA``. The accelerator type is reported as
``FURIOSA_<ARCH>`` where ``<ARCH>`` is the architecture identifier
that the Furiosa SMI SDK exposes via its ``Arch`` enum. The current
SDK variants are ``Rngd``, ``RngdS``, ``RngdMax`` and ``RngdPlus``,
which surface here as ``FURIOSA_RNGD``, ``FURIOSA_RNGDS``,
``FURIOSA_RNGDMAX`` and ``FURIOSA_RNGDPLUS`` respectively.
Supporting any architecture the SDK reports keeps this manager
forward-compatible with new SKUs as Furiosa adds them to
``furiosa_smi_py``.
Device visibility is tracked through the ``FURIOSA_DEVICES``
environment variable, formatted as ``npu:<id>`` tokens. The value
can be passed to the ``furiosa-llm`` CLI (e.g.,
``furiosa-llm serve --devices "$FURIOSA_DEVICES" ...``). When
invoking the ``furiosa_llm.LLM`` Python API directly, the assigned
devices must be passed explicitly, e.g.
``LLM(model_path, devices=os.environ["FURIOSA_DEVICES"])``;
``LLM(devices=None)`` allocates all visible NPUs and would bypass
Ray's per-worker isolation.
"""
@staticmethod
def get_resource_name() -> str:
return "FURIOSA"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return FURIOSA_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
visible_devices = os.environ.get(
FuriosaAcceleratorManager.get_visible_accelerator_ids_env_var()
)
if visible_devices is None:
return None
if visible_devices == "":
return []
return [
_strip_npu_prefix(token)
for token in visible_devices.split(",")
if token.strip()
]
@staticmethod
def get_current_node_num_accelerators() -> int:
"""Detects the number of Furiosa NPU devices on the current machine."""
list_devices = _get_furiosa_list_devices()
if list_devices is None:
return 0
try:
return len(list_devices())
except Exception as e:
logger.debug("Could not list Furiosa NPU devices: %s", e)
return 0
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Gets the architecture of the Furiosa NPU on the current node.
Returns a string like ``FURIOSA_RNGD``, ``FURIOSA_RNGDMAX``,
``FURIOSA_RNGDS``, or ``FURIOSA_RNGDPLUS``. Ray assumes a single
accelerator type per node, so the architecture of the first detected
device is used.
The architecture is read from
``device.device_info().arch()`` to mirror the upstream Furiosa SMI
interface. The Arch enum string is normalized into an
accelerator-type label: ``+`` is mapped to ``plus`` so distinct
SKUs do not collide (``rngd+`` becomes ``FURIOSA_RNGDPLUS``,
matching the PyO3 enum form ``RngdPlus``), and any remaining
non-alphanumeric characters are stripped.
"""
list_devices = _get_furiosa_list_devices()
if list_devices is None:
return None
try:
devices = list_devices()
if not devices:
return None
arch_obj = devices[0].device_info().arch()
if arch_obj is None:
return None
# PyO3 enums typically stringify as "<EnumName.Variant>",
# "EnumName.Variant", or just "Variant". Take the trailing
# component.
raw = str(arch_obj).split(".")[-1].strip()
if not raw:
return None
# Map special suffixes to their alphabetic equivalents so that the
# ``Arch::ToString`` form ("rngd+") and the PyO3 enum form
# ("RngdPlus") produce the same Ray accelerator type label.
# Without this, stripping "+" would collapse "rngd+" into
# "rngd", colliding with the distinct ``Rngd`` SKU.
raw = raw.replace("+", "plus")
normalized = "".join(ch for ch in raw if ch.isalnum()).upper()
if not normalized:
return None
return f"FURIOSA_{normalized}"
except Exception as e:
logger.debug("Failed to detect Furiosa NPU type: %s", e)
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if isinstance(quantity, float) and not quantity.is_integer():
return (
False,
f"{FuriosaAcceleratorManager.get_resource_name()} resource quantity"
" must be a whole number. Furiosa NPUs do not support"
" fractional resource sharing."
f" The specified quantity {quantity} is invalid.",
)
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_furiosa_devices: List[str],
) -> None:
if env_bool(NOSET_FURIOSA_VISIBLE_DEVICES_ENV_VAR, False):
return
formatted = ",".join(
f"{_FURIOSA_DEVICE_PREFIX}{_strip_npu_prefix(str(d))}"
for d in visible_furiosa_devices
)
os.environ[
FuriosaAcceleratorManager.get_visible_accelerator_ids_env_var()
] = formatted
+122
View File
@@ -0,0 +1,122 @@
import logging
import os
from functools import lru_cache
from importlib.util import find_spec
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
HABANA_VISIBLE_DEVICES_ENV_VAR = "HABANA_VISIBLE_MODULES"
NOSET_HABANA_VISIBLE_MODULES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_HABANA_VISIBLE_MODULES"
@lru_cache()
def is_package_present(package_name: str) -> bool:
try:
return find_spec(package_name) is not None
except ModuleNotFoundError:
return False
HPU_PACKAGE_AVAILABLE = is_package_present("habana_frameworks")
class HPUAcceleratorManager(AcceleratorManager):
"""Intel Habana(HPU) accelerators."""
@staticmethod
def get_resource_name() -> str:
return "HPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return HABANA_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
hpu_visible_devices = os.environ.get(
HPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if hpu_visible_devices is None:
return None
if hpu_visible_devices == "":
return []
return list(hpu_visible_devices.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
"""Attempt to detect the number of HPUs on this machine.
Returns:
The number of HPUs if any were detected, otherwise 0.
"""
if HPU_PACKAGE_AVAILABLE:
import habana_frameworks.torch.hpu as torch_hpu
if torch_hpu.is_available():
return torch_hpu.device_count()
else:
logging.info("HPU devices not available")
return 0
else:
return 0
@staticmethod
def is_initialized() -> bool:
"""Attempt to check if HPU backend is initialized.
Returns:
True if backend initialized else False.
"""
if HPU_PACKAGE_AVAILABLE:
import habana_frameworks.torch.hpu as torch_hpu
if torch_hpu.is_available() and torch_hpu.is_initialized():
return True
else:
return False
else:
return False
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Attempt to detect the HPU family type.
Returns:
The device name (GAUDI, GAUDI2) if detected else None.
"""
if HPUAcceleratorManager.is_initialized():
import habana_frameworks.torch.hpu as torch_hpu
return f"Intel-{torch_hpu.get_device_name()}"
else:
logging.info("HPU type cannot be detected")
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if isinstance(quantity, float) and not quantity.is_integer():
return (
False,
f"{HPUAcceleratorManager.get_resource_name()} resource quantity"
" must be whole numbers. "
f"The specified quantity {quantity} is invalid.",
)
else:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_hpu_devices: List[str],
) -> None:
if env_bool(NOSET_HABANA_VISIBLE_MODULES_ENV_VAR, False):
return
os.environ[
HPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_hpu_devices])
@@ -0,0 +1,104 @@
import logging
import os
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
ONEAPI_DEVICE_SELECTOR_ENV_VAR = "ONEAPI_DEVICE_SELECTOR"
NOSET_ONEAPI_DEVICE_SELECTOR_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_ONEAPI_DEVICE_SELECTOR"
ONEAPI_DEVICE_BACKEND_TYPE = "level_zero"
ONEAPI_DEVICE_TYPE = "gpu"
class IntelGPUAcceleratorManager(AcceleratorManager):
"""Intel GPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "GPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return ONEAPI_DEVICE_SELECTOR_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
oneapi_visible_devices = os.environ.get(
IntelGPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if oneapi_visible_devices is None:
return None
if oneapi_visible_devices == "":
return []
if oneapi_visible_devices == "NoDevFiles":
return []
prefix = ONEAPI_DEVICE_BACKEND_TYPE + ":"
return list(oneapi_visible_devices.split(prefix)[1].split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
try:
import dpctl
except ImportError:
dpctl = None
if dpctl is None:
return 0
num_gpus = 0
try:
dev_info = ONEAPI_DEVICE_BACKEND_TYPE + ":" + ONEAPI_DEVICE_TYPE
context = dpctl.SyclContext(dev_info)
num_gpus = context.device_count
except Exception:
num_gpus = 0
return num_gpus
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Get the name of first Intel GPU. (supposed only one GPU type on a node)
Example:
name: 'Intel(R) Data Center GPU Max 1550'
return name: 'Intel-GPU-Max-1550'
Returns:
A string representing the name of Intel GPU type.
"""
try:
import dpctl
except ImportError:
dpctl = None
if dpctl is None:
return None
accelerator_type = None
try:
dev_info = ONEAPI_DEVICE_BACKEND_TYPE + ":" + ONEAPI_DEVICE_TYPE + ":0"
dev = dpctl.SyclDevice(dev_info)
accelerator_type = "Intel-GPU-" + "-".join(dev.name.split(" ")[-2:])
except Exception:
accelerator_type = None
return accelerator_type
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_xpu_devices: List[str],
) -> None:
if env_bool(NOSET_ONEAPI_DEVICE_SELECTOR_ENV_VAR, False):
return
prefix = ONEAPI_DEVICE_BACKEND_TYPE + ":"
os.environ[
IntelGPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = prefix + ",".join([str(i) for i in visible_xpu_devices])
@@ -0,0 +1,90 @@
import logging
import os
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
logger = logging.getLogger(__name__)
CUDA_VISIBLE_DEVICES_ENV_VAR = "CUDA_VISIBLE_DEVICES"
NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES"
class MetaxGPUAcceleratorManager(AcceleratorManager):
"""Metax GPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "GPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return CUDA_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
cuda_visible_devices = os.environ.get(
MetaxGPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if cuda_visible_devices is None:
return None
if cuda_visible_devices == "":
return []
return list(cuda_visible_devices.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
try:
import pymxsml.mxsml_extension as pymxsml
try:
pymxsml.mxSmlExInit()
except pymxsml.MXSMLEXError:
return 0
device_count = pymxsml.mxSmlExDeviceGetCount()
pymxsml.mxSmlExShutdown()
return device_count
except Exception as e:
logger.debug("Could not import pymxsml: %s", e)
return 0
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
try:
import pymxsml.mxsml_extension as pymxsml
try:
pymxsml.mxSmlExInit()
except pymxsml.MXSMLEXError:
return None
device_name = None
device_count = pymxsml.mxSmlExDeviceGetCount()
if device_count > 0:
handle = pymxsml.mxSmlExDeviceGetHandleByIndex(0)
device_name = pymxsml.mxSmlExDeviceGetName(handle)
if isinstance(device_name, bytes):
device_name = device_name.decode("utf-8")
pymxsml.mxSmlExShutdown()
return device_name
except Exception:
logger.warning("Failed to detect GPU type.", exc_info=True)
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_cuda_devices: List[str],
) -> None:
if os.environ.get(NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR):
return
os.environ[
MetaxGPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join(visible_cuda_devices)
+133
View File
@@ -0,0 +1,133 @@
import json
import logging
import os
import subprocess
import sys
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
NEURON_RT_VISIBLE_CORES_ENV_VAR = "NEURON_RT_VISIBLE_CORES"
NOSET_AWS_NEURON_RT_VISIBLE_CORES_ENV_VAR = (
"RAY_EXPERIMENTAL_NOSET_NEURON_RT_VISIBLE_CORES"
)
# https://awsdocs-neuron.readthedocs-hosted.com/en/latest/general/arch/neuron-hardware/inf2-arch.html#aws-inf2-arch
# https://awsdocs-neuron.readthedocs-hosted.com/en/latest/general/arch/neuron-hardware/trn1-arch.html#aws-trn1-arch
# Subject to removal after the information is available via public API
AWS_NEURON_INSTANCE_MAP = {
"trn1.2xlarge": 2,
"trn1.32xlarge": 32,
"trn1n.32xlarge": 32,
"inf2.xlarge": 2,
"inf2.8xlarge": 2,
"inf2.24xlarge": 12,
"inf2.48xlarge": 24,
}
class NeuronAcceleratorManager(AcceleratorManager):
"""AWS Inferentia and Trainium accelerators."""
@staticmethod
def get_resource_name() -> str:
return "neuron_cores"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return NEURON_RT_VISIBLE_CORES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
neuron_visible_cores = os.environ.get(
NeuronAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if neuron_visible_cores is None:
return None
if neuron_visible_cores == "":
return []
return list(neuron_visible_cores.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
"""
Attempt to detect the number of Neuron cores on this machine.
Returns:
The number of Neuron cores if any were detected, otherwise 0.
"""
nc_count: int = 0
neuron_path = "/opt/aws/neuron/bin/"
if sys.platform.startswith("linux") and os.path.isdir(neuron_path):
result = subprocess.run(
[os.path.join(neuron_path, "neuron-ls"), "--json-output"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode == 0 and result.stdout:
neuron_devices = json.loads(result.stdout)
for neuron_device in neuron_devices:
nc_count += neuron_device.get("nc_count", 0)
return nc_count
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
from ray.util.accelerators import AWS_NEURON_CORE
return AWS_NEURON_CORE
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if isinstance(quantity, float) and not quantity.is_integer():
return (
False,
f"{NeuronAcceleratorManager.get_resource_name()} resource quantity"
" must be whole numbers. "
f"The specified quantity {quantity} is invalid.",
)
else:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_neuron_core_ids: List[str],
) -> None:
"""Set the NEURON_RT_VISIBLE_CORES environment variable based on
given visible_neuron_core_ids.
Args:
visible_neuron_core_ids: List of str representing core IDs.
"""
if env_bool(NOSET_AWS_NEURON_RT_VISIBLE_CORES_ENV_VAR, False):
return
os.environ[
NeuronAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_neuron_core_ids])
@staticmethod
def get_ec2_instance_num_accelerators(
instance_type: str, instances: dict
) -> Optional[int]:
# TODO: AWS SDK (public API) doesn't yet expose the NeuronCore
# information. It will be available (work-in-progress)
# as xxAcceleratorInfo in InstanceTypeInfo.
# https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_InstanceTypeInfo.html
# See https://github.com/ray-project/ray/issues/38473
return AWS_NEURON_INSTANCE_MAP.get(instance_type.lower(), None)
@staticmethod
def get_ec2_instance_accelerator_type(
instance_type: str, instances: dict
) -> Optional[str]:
from ray.util.accelerators import AWS_NEURON_CORE
return AWS_NEURON_CORE
+100
View File
@@ -0,0 +1,100 @@
import glob
import logging
import os
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
ASCEND_RT_VISIBLE_DEVICES_ENV_VAR = "ASCEND_RT_VISIBLE_DEVICES"
NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR = (
"RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES"
)
class NPUAcceleratorManager(AcceleratorManager):
"""Ascend NPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "NPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return ASCEND_RT_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
ascend_visible_devices = os.environ.get(
NPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if ascend_visible_devices is None:
return None
if ascend_visible_devices == "":
return []
if ascend_visible_devices == "NoDevFiles":
return []
return list(ascend_visible_devices.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
"""Attempt to detect the number of NPUs on this machine.
NPU chips are represented as devices within `/dev/`, either as `/dev/davinci?`.
Returns:
The number of NPUs if any were detected, otherwise 0.
"""
try:
import acl
device_count, ret = acl.rt.get_device_count()
if ret == 0:
return device_count
except Exception as e:
logger.debug("Could not import AscendCL: %s", e)
try:
npu_files = glob.glob("/dev/davinci[0-9]*")
return len(npu_files)
except Exception as e:
logger.debug("Failed to detect number of NPUs: %s", e)
return 0
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Get the type of the Ascend NPU on the current node.
Returns:
A string of the type, such as "Ascend910A", "Ascend910B", "Ascend310P1".
"""
try:
import acl
return acl.get_soc_name()
except Exception:
logger.exception("Failed to detect NPU type.")
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_npu_devices: List[str],
) -> None:
if env_bool(NOSET_ASCEND_RT_VISIBLE_DEVICES_ENV_VAR, False):
return
os.environ[
NPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_npu_devices])
@@ -0,0 +1,145 @@
import logging
import os
import re
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
CUDA_VISIBLE_DEVICES_ENV_VAR = "CUDA_VISIBLE_DEVICES"
NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES"
# Capture the accelerator model from the NVML device name: the run of leading
# all-caps tokens (e.g. "RTX", "PRO") up to and including the first token that
# contains a digit. This keeps datacenter cards stable ("Tesla V100-SXM2-16GB"
# -> "V100", "NVIDIA A100-SXM4-40GB" -> "A100") while disambiguating the RTX
# line, whose first token is only a brand prefix ("NVIDIA RTX PRO 6000 Blackwell
# Server Edition" -> "RTX PRO 6000"). A trailing SKU suffix after a hyphen is
# dropped. Mixed-case consumer names ("NVIDIA GeForce RTX 5090") don't match and
# fall back to a hyphen-joined product name in _gpu_name_to_accelerator_type.
NVIDIA_GPU_NAME_PATTERN = re.compile(r"\w+\s+((?:[A-Z]+\s+)*[A-Z0-9]*\d[A-Z0-9]*)")
class NvidiaGPUAcceleratorManager(AcceleratorManager):
"""NVIDIA GPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "GPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return CUDA_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
cuda_visible_devices = os.environ.get(
NvidiaGPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if cuda_visible_devices is None:
return None
if cuda_visible_devices == "":
return []
if cuda_visible_devices == "NoDevFiles":
return []
return list(cuda_visible_devices.split(","))
@staticmethod
def get_current_node_num_accelerators() -> int:
import ray._private.thirdparty.pynvml as pynvml
try:
pynvml.nvmlInit()
except pynvml.NVMLError:
return 0 # pynvml init failed
device_count = pynvml.nvmlDeviceGetCount()
pynvml.nvmlShutdown()
return device_count
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
import ray._private.thirdparty.pynvml as pynvml
try:
pynvml.nvmlInit()
except pynvml.NVMLError:
return None # pynvml init failed
device_count = pynvml.nvmlDeviceGetCount()
cuda_device_type = None
if device_count > 0:
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
device_name = pynvml.nvmlDeviceGetName(handle)
if isinstance(device_name, bytes):
device_name = device_name.decode("utf-8")
cuda_device_type = (
NvidiaGPUAcceleratorManager._gpu_name_to_accelerator_type(device_name)
)
pynvml.nvmlShutdown()
return cuda_device_type
@staticmethod
def _gpu_name_to_accelerator_type(name):
if name is None:
return None
match = NVIDIA_GPU_NAME_PATTERN.match(name)
result = match.group(1).replace(" ", "-") if match else None
if result and len(result) > 1:
return result
# The pattern above requires an all-uppercase/numeric model token, which
# works for datacenter cards ("Tesla V100-SXM2-16GB" -> "V100",
# "NVIDIA RTX PRO 6000 ..." -> "RTX-PRO-6000") but not for consumer
# cards whose product line is mixed case ("NVIDIA GeForce RTX 5090").
# Fall back to a hyphen-joined product name so callers get a useful
# accelerator_type label like "GeForce-RTX-5090".
cleaned = re.sub(r"^NVIDIA\s+", "", name).strip()
return cleaned.replace(" ", "-") if cleaned else None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_cuda_devices: List[str],
) -> None:
if env_bool(NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR, False):
return
os.environ[
NvidiaGPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_cuda_devices])
@staticmethod
def get_ec2_instance_num_accelerators(
instance_type: str, instances: dict
) -> Optional[int]:
if instance_type not in instances:
return None
gpus = instances[instance_type].get("GpuInfo", {}).get("Gpus")
if gpus is not None:
# TODO(ameer): currently we support one gpu type per node.
assert len(gpus) == 1
return gpus[0]["Count"]
return None
@staticmethod
def get_ec2_instance_accelerator_type(
instance_type: str, instances: dict
) -> Optional[str]:
if instance_type not in instances:
return None
gpus = instances[instance_type].get("GpuInfo", {}).get("Gpus")
if gpus is not None:
# TODO(ameer): currently we support one gpu type per node.
assert len(gpus) == 1
return gpus[0]["Name"]
return None
+81
View File
@@ -0,0 +1,81 @@
import logging
import os
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
RBLN_RT_VISIBLE_DEVICES_ENV_VAR = "RBLN_DEVICES"
NOSET_RBLN_RT_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_RBLN_RT_VISIBLE_DEVICES"
class RBLNAcceleratorManager(AcceleratorManager):
"""Rebellions RBLN accelerators."""
@staticmethod
def get_resource_name() -> str:
return "RBLN"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return RBLN_RT_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
visible_devices = os.environ.get(
RBLNAcceleratorManager.get_visible_accelerator_ids_env_var()
)
if visible_devices is None:
return None
if visible_devices == "":
return []
return visible_devices.split(",")
@staticmethod
def get_current_node_num_accelerators() -> int:
"""Detects the number of RBLN devices on the current machine."""
try:
from rebel import device_count
return device_count()
except Exception as e:
logger.debug("Could not detect RBLN devices: %s", e)
return 0
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Gets the type of RBLN NPU on the current node."""
try:
from rebel import get_npu_name
return get_npu_name()
except Exception as e:
logger.exception("Failed to detect RBLN NPU type: %s", e)
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if isinstance(quantity, float) and not quantity.is_integer():
return (
False,
f"{RBLNAcceleratorManager.get_resource_name()} resource quantity"
" must be whole numbers. "
f"The specified quantity {quantity} is invalid.",
)
else:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_rbln_devices: List[str],
) -> None:
if env_bool(NOSET_RBLN_RT_VISIBLE_DEVICES_ENV_VAR, False):
return
os.environ[
RBLNAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join(map(str, visible_rbln_devices))
+766
View File
@@ -0,0 +1,766 @@
import glob
import logging
import os
import re
from functools import lru_cache
from typing import Dict, List, Optional, Set, Tuple
import requests
import ray
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
from ray.util.placement_group import (
PlacementGroup,
placement_group,
remove_placement_group,
)
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
logger = logging.getLogger(__name__)
TPU_VALID_CHIP_OPTIONS = (1, 2, 4, 8)
GKE_TPU_ACCELERATOR_TYPE_ENV_VAR = "TPU_ACCELERATOR_TYPE"
GKE_TPU_TOPOLOGY_ENV_VAR = "TPU_TOPOLOGY"
GKE_TPU_WORKER_ID_ENV_VAR = "TPU_WORKER_ID"
GKE_TPU_NAME_ENV_VAR = "TPU_NAME"
# Constants for accessing the `accelerator-type` from TPU VM
# instance metadata.
# See https://cloud.google.com/compute/docs/metadata/overview
# for more details about VM instance metadata.
GCE_TPU_ACCELERATOR_ENDPOINT = (
"http://metadata.google.internal/computeMetadata/v1/instance/attributes/"
)
GCE_TPU_HEADERS = {"Metadata-Flavor": "Google"}
GCE_TPU_ACCELERATOR_KEY = "accelerator-type"
GCE_TPU_ENV_KEY = "tpu-env"
GCE_TPU_INSTANCE_ID_KEY = "instance-id"
GCE_TPU_WORKER_ID_KEY = "agent-worker-number"
TPU_VISIBLE_CHIPS_ENV_VAR = "TPU_VISIBLE_CHIPS"
NOSET_TPU_VISIBLE_CHIPS_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_TPU_VISIBLE_CHIPS"
# The following defines environment variables that allow
# us to access a subset of TPU visible chips.
#
# See: https://github.com/google/jax/issues/14977 for an example/more details.
TPU_CHIPS_PER_HOST_BOUNDS_ENV_VAR = "TPU_CHIPS_PER_HOST_BOUNDS"
TPU_CHIPS_PER_HOST_BOUNDS_1_CHIP_CONFIG = "1,1,1"
TPU_CHIPS_PER_HOST_BOUNDS_2_CHIP_CONFIG = "1,2,1"
TPU_HOST_BOUNDS_ENV_VAR = "TPU_HOST_BOUNDS"
TPU_SINGLE_HOST_BOUNDS = "1,1,1"
# By default TPU VMs come with 4 chips per host and 2 tensorcores per chip.
# For more details: https://cloud.google.com/tpu/docs/system-architecture-tpu-vm
DEFAULT_TPU_NUM_CHIPS_PER_HOST = 4
DEFAULT_TPU_NUM_CORES_PER_CHIP = 2
# Accelerators that support up to 8 chips per host for single-host topologies: v5e, v6e
TPU_8_CHIPS_PER_HOST_TYPES = ("v5litepod", "v6e")
# Topologies that are always sub-host or single-host
TPU_SINGLE_HOST_TOPOLOGIES = ("1x1", "2x2", "2x4")
# Accelerators that are 2 cores per chip: v2, v3, v4, v5p, v7x
# Accelerators that are 1 core per chip: v5e, v6e
SINGLE_CORE_TPU_TYPES = ("v5litepod", "v6e")
# The valid TPU types.
VALID_TPU_TYPES = ("v2", "v3", "v4", "v5p", "v5litepod", "v6e", "v7x")
# This is only used to construct TPU 3D topologies
def _get_larger_3d_topologies(max_x: int, max_y: int, max_z: int) -> Set[str]:
"""Returns a set of larger 3D TPU topologies given the max x,y,z value. Using DEFAULT_TPU_NUM_CHIPS_PER_HOST as increment"""
topologies = set()
for x in range(
DEFAULT_TPU_NUM_CHIPS_PER_HOST, max_x + 1, DEFAULT_TPU_NUM_CHIPS_PER_HOST
):
for y in range(
DEFAULT_TPU_NUM_CHIPS_PER_HOST, max_y + 1, DEFAULT_TPU_NUM_CHIPS_PER_HOST
):
for z in range(
DEFAULT_TPU_NUM_CHIPS_PER_HOST,
max_z + 1,
DEFAULT_TPU_NUM_CHIPS_PER_HOST,
):
topologies.add(f"{x}x{y}x{z}")
return topologies
# The valid TPU topologies for each of the TPU types.
VALID_TPU_TOPOLOGY = {
"v2": {"4x4", "4x8", "8x8", "8x16", "16x16"},
"v3": {"4x4", "4x8", "8x8", "8x16", "16x16", "16x32", "32x32"},
"v4": {"2x2x1", "2x2x2", "2x2x4", "2x4x4"}.union(
_get_larger_3d_topologies(12, 12, 16)
),
"v5p": {
"2x2x1",
"2x2x2",
"2x2x4",
"2x4x4",
}.union(_get_larger_3d_topologies(16, 16, 24)),
"v5litepod": {"1x1", "2x2", "2x4", "2x8", "4x4", "4x8", "8x8", "8x16", "16x16"},
"v6e": {"1x1", "2x2", "2x4", "2x8", "4x4", "4x8", "8x8", "8x16", "16x16"},
"v7x": {
"2x2x1",
"2x2x2",
"2x2x4",
"2x4x4",
"4x4x4",
"4x4x8",
"4x8x8",
"8x8x8",
"8x8x16",
"8x16x16",
},
}
def _get_tpu_metadata(key: str) -> Optional[str]:
"""Poll and get TPU metadata."""
try:
accelerator_type_request = requests.get(
os.path.join(GCE_TPU_ACCELERATOR_ENDPOINT, key),
headers=GCE_TPU_HEADERS,
)
if (
accelerator_type_request.status_code == 200
and accelerator_type_request.text
):
return accelerator_type_request.text
else:
logging.debug(
"Unable to poll TPU GCE Metadata. Got "
f"status code: {accelerator_type_request.status_code} and "
f"content: {accelerator_type_request.text}"
)
except requests.RequestException as e:
logging.debug("Unable to poll the TPU GCE Metadata: %s", e)
return None
def _accelerator_type_check(accelerator_type: str):
if not accelerator_type.startswith(VALID_TPU_TYPES):
raise ValueError(
f"Invalid accelerator type: {accelerator_type}. Must start with one of: {VALID_TPU_TYPES}"
)
def get_total_chips_from_accelerator_type(accelerator_type: str) -> int:
"""Calculates total chips from a GCP accelerator ("pod") type string (e.g. "v6e-16")."""
_accelerator_type_check(accelerator_type)
parts = accelerator_type.split("-")
if len(parts) < 2:
raise ValueError(
f"Accelerator type must include size (e.g. 'v6e-8'), got: {accelerator_type}"
)
num_cores = int(parts[1])
cores_per_chip = get_tpu_cores_per_chip(accelerator_type)
return num_cores // cores_per_chip
def get_num_tpu_visible_chips_per_host(accelerator_type: str) -> int:
_accelerator_type_check(accelerator_type)
if accelerator_type.startswith(TPU_8_CHIPS_PER_HOST_TYPES):
total_chips = get_total_chips_from_accelerator_type(accelerator_type)
# Sub/single-host topologies return their exact chip count
if total_chips <= 8:
return total_chips
# Multi-host topologies default to 4 visible chips per host
return DEFAULT_TPU_NUM_CHIPS_PER_HOST
def get_tpu_cores_per_chip(accelerator_type: str) -> int:
_accelerator_type_check(accelerator_type)
if accelerator_type.startswith(SINGLE_CORE_TPU_TYPES):
return 1
return DEFAULT_TPU_NUM_CORES_PER_CHIP
def get_num_chips_from_topology(topology: str) -> int:
"""
Calculates the total number of chips in a TPU topology.
Ex: "2x2x2" -> 8
"""
total_chips = 1
for dim in topology.strip().lower().split("x"):
total_chips *= int(dim)
return total_chips
def infer_tpu_pod_type_from_topology(
topology: str, accelerator_type: str
) -> Optional[str]:
"""Infer the TPU pod type (e.g. v4-32) from topology and accelerator type."""
if not topology or not accelerator_type:
return None
try:
num_chips = get_num_chips_from_topology(topology)
generation = accelerator_type.lower().replace("tpu-", "")
num_cores = num_chips * get_tpu_cores_per_chip(generation)
return f"{generation}-{num_cores}"
except Exception as e:
raise ValueError(
f"Failed to infer pod type from topology '{topology}' "
f"and type '{accelerator_type}'"
) from e
def fetch_tpu_slice_name_from_pg(pg):
@ray.remote(num_cpus=0)
def _get_tpu_slice_name():
return TPUAcceleratorManager.get_current_node_tpu_name()
tpu_name_ref = _get_tpu_slice_name.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg, placement_group_bundle_index=0
)
).remote()
return ray.get(tpu_name_ref)
def get_chips_per_host(topology: str, accelerator_version: str) -> int:
"""Get the number of chips per host based on topology and accelerator version.
Rules for determining the default number of chips per host:
- Default for most TPU generations (v4, v5p, v7x, etc.) is 4 chips per host.
- For v5e and v6e:
- Topologies with <= 8 chips use the exact chip count (e.g. 1x1 -> 1).
These topologies are always sub or single-host.
- Multi-host topologies (> 8 chips) default to 4-chip hosts.
Args:
topology: The TPU topology string (e.g. "2x2x2", "2x4").
accelerator_version: The accelerator version string (e.g. "v4", "v6e").
Returns:
The default number of chips per host for the given configuration.
"""
total_chips = get_num_chips_from_topology(topology)
# Check for 8-chip host types (v5litepod, v6e) for single host setups
if (
accelerator_version.strip().lower() in TPU_8_CHIPS_PER_HOST_TYPES
and topology.strip().lower() in TPU_SINGLE_HOST_TOPOLOGIES
):
return total_chips
return DEFAULT_TPU_NUM_CHIPS_PER_HOST
DEFAULT_TPU_HEAD_RESERVATION_TIMEOUT_S: float = 100.0
def reserve_tpu_slice(
topology: str,
accelerator_type: str,
timeout_s: Optional[float] = DEFAULT_TPU_HEAD_RESERVATION_TIMEOUT_S,
) -> Optional[Tuple[str, PlacementGroup]]:
"""Reserves a TPU slice using its head resource and returns the slice name.
This enables gang scheduling of training workers with multi-host TPUs.
This is used by JaxTrainer with TPUs in Ray Train.
Args:
topology: The TPU topology string (e.g. "2x2x2").
accelerator_type: The accelerator type of the node (e.g. "TPU-V4").
timeout_s: The maximum time in seconds to wait for the TPU head
placement group to become ready. The head reservation must succeed
before the slice name can be retrieved, so this call is necessarily
blocking. Defaults to ``DEFAULT_TPU_HEAD_RESERVATION_TIMEOUT_S``.
Pass ``None`` to wait indefinitely.
Returns:
A tuple of a string representing a unique TPU slice name and the placement
group handle reserving the TPU head.
Raises:
TimeoutError: If the TPU head placement group does not become ready
within ``timeout_s`` seconds.
"""
pod_type = infer_tpu_pod_type_from_topology(topology, accelerator_type)
if pod_type is None:
return None
# Reserve a slice by creating a placement group on the TPU head.
head_label_selector = {
"ray.io/tpu-worker-id": "0",
"ray.io/tpu-pod-type": pod_type,
}
head_placement_group = placement_group(
bundles=[{f"TPU-{pod_type}-head": 1}],
bundle_label_selector=[head_label_selector],
)
logger.debug(
"Waiting up to %s seconds to reserve multi-host slice head.", timeout_s
)
ready, _ = ray.wait([head_placement_group.ready()], timeout=timeout_s)
if not ready:
# Clean up the pending head reservation so that resources are not
# held while the caller decides whether to retry.
try:
remove_placement_group(head_placement_group)
except Exception:
logger.exception(
"Failed to clean up pending TPU head placement group after timeout."
)
raise TimeoutError(
"Failed to reserve TPU head for slice with shape: {} after {} "
"seconds. Ensure your cluster has sufficient resources. Requesting "
"TPU head node with labels: {}. Current resources: {}".format(
pod_type,
timeout_s,
head_label_selector,
ray.available_resources(),
)
)
# Retrieve the unique slice ID.
slice_name = fetch_tpu_slice_name_from_pg(head_placement_group)
if slice_name is None:
raise RuntimeError(
"Failed to retrieve TPU slice name after reserving head placement group. "
"Ensure that TPU slice metadata is available and correctly configured on multi-host nodes."
)
return (slice_name, head_placement_group)
class TPUAcceleratorManager(AcceleratorManager):
"""Google TPU accelerators."""
@staticmethod
def get_resource_name() -> str:
return "TPU"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return TPU_VISIBLE_CHIPS_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
tpu_visible_chips = os.environ.get(
TPUAcceleratorManager.get_visible_accelerator_ids_env_var(), None
)
if tpu_visible_chips is None:
return None
if tpu_visible_chips == "":
return []
return list(tpu_visible_chips.split(","))
@staticmethod
@lru_cache()
def get_current_node_num_accelerators() -> int:
"""Attempt to detect the number of TPUs on this machine.
TPU chips are represented as devices within `/dev/`, either as
`/dev/accel*` or `/dev/vfio/*`.
Returns:
The number of TPUs if any were detected, otherwise 0.
"""
# Real TPU chips are exposed as character devices at /dev/accel0,
# /dev/accel1, etc. NVIDIA drivers 570.x and later (Blackwell-class
# GPUs such as the RTX 5090) instead create /dev/accel as a *directory*
# containing /dev/accel/accel0, which the non-recursive glob below
# would otherwise miscount as a TPU chip. Filter directory entries out
# so both GKE and GCE TPU detection keep working while rejecting the
# NVIDIA false positive.
accel_chips = [p for p in glob.glob("/dev/accel*") if not os.path.isdir(p)]
if accel_chips:
return len(accel_chips)
try:
vfio_entries = os.listdir("/dev/vfio")
numeric_entries = [int(entry) for entry in vfio_entries if entry.isdigit()]
return len(numeric_entries)
except FileNotFoundError as e:
logger.debug("Failed to detect number of TPUs: %s", e)
return 0
@staticmethod
def is_valid_tpu_accelerator_type(tpu_accelerator_type: str) -> bool:
"""Check whether the tpu accelerator_type is formatted correctly.
The accelerator_type field typically follows a form of v{generation}-{cores/chips},
but newer generations like 7x may follow tpu{generation}-{cores/chips}.
See the following for more information:
https://cloud.google.com/sdk/gcloud/reference/compute/tpus/tpu-vm/accelerator-types/describe
Args:
tpu_accelerator_type: The string representation of the accelerator type
to be checked for validity.
Returns:
True if it's valid, false otherwise.
"""
# 1. Legacy format: v2-8, v3-32.
# 2. Newer format with letters in generation: v5litepod-16, v6e-4.
# 3. Ironwood TPU format which contains a tpu prefix: tpu7x-16.
expected_pattern = re.compile(r"^(v|tpu)\d+[a-zA-Z]*-\d+$")
if not expected_pattern.match(tpu_accelerator_type):
return False
return True
@staticmethod
def is_valid_tpu_accelerator_topology(
tpu_accelerator_version: str, tpu_topology: str
) -> bool:
"""Check whether the tpu topology is valid.
The accelerator_type field follows a form of v{generation}.
The accelerator_topology field follows either the form {A}x{B} or {A}x{B}x{C} depending on the v{generation}
Args:
tpu_accelerator_version: The string representation of the accelerator version. (e.g. v6e, V5P)
tpu_topology: The string representation of the accelerator topology
to be checked for validity
Returns:
True if it's a valid topology, False otherwise.
"""
tpu_version_formatted = tpu_accelerator_version.strip().lower().split("-")[0]
if tpu_version_formatted.startswith("tpu"):
tpu_version_formatted = "v" + tpu_version_formatted[3:]
if (
tpu_version_formatted.lower() not in VALID_TPU_TOPOLOGY
or tpu_topology.strip().lower()
not in VALID_TPU_TOPOLOGY[tpu_version_formatted]
):
return False
return True
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if quantity not in TPU_VALID_CHIP_OPTIONS:
return (
False,
f"The number of requested 'TPU' was set to {quantity} which "
"is not a supported chip configuration. Supported configs: "
f"{TPU_VALID_CHIP_OPTIONS}",
)
else:
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_tpu_chips: List[str],
) -> None:
"""Set TPU environment variables based on the provided visible_tpu_chips.
To access a subset of the TPU visible chips, we must use a combination of
environment variables that tells the compiler (via ML framework) the:
- Visible chips
- The physical bounds of chips per host
- The host bounds within the context of a TPU pod.
See: https://github.com/google/jax/issues/14977 for an example/more details.
Args:
visible_tpu_chips: List of str representing TPU chips.
"""
if env_bool(NOSET_TPU_VISIBLE_CHIPS_ENV_VAR, False):
return
num_visible_tpu_chips = len(visible_tpu_chips)
num_accelerators_on_node = (
TPUAcceleratorManager.get_current_node_num_accelerators()
)
if num_visible_tpu_chips == num_accelerators_on_node:
# Let the ML framework use the defaults
os.environ.pop(TPU_CHIPS_PER_HOST_BOUNDS_ENV_VAR, None)
os.environ.pop(TPU_HOST_BOUNDS_ENV_VAR, None)
return
os.environ[
TPUAcceleratorManager.get_visible_accelerator_ids_env_var()
] = ",".join([str(i) for i in visible_tpu_chips])
if num_visible_tpu_chips == 1:
os.environ[
TPU_CHIPS_PER_HOST_BOUNDS_ENV_VAR
] = TPU_CHIPS_PER_HOST_BOUNDS_1_CHIP_CONFIG
os.environ[TPU_HOST_BOUNDS_ENV_VAR] = TPU_SINGLE_HOST_BOUNDS
elif num_visible_tpu_chips == 2:
os.environ[
TPU_CHIPS_PER_HOST_BOUNDS_ENV_VAR
] = TPU_CHIPS_PER_HOST_BOUNDS_2_CHIP_CONFIG
os.environ[TPU_HOST_BOUNDS_ENV_VAR] = TPU_SINGLE_HOST_BOUNDS
@staticmethod
def get_current_node_tpu_pod_type() -> Optional[str]:
"""Get the TPU pod type of the current node if applicable.
Individual TPU VMs within a TPU pod must know what type
of pod it is a part of. This is necessary for the
ML framework to work properly.
The logic is different if the TPU was provisioned via:
```
gcloud tpus tpu-vm create ...
```
(i.e. a GCE VM), vs through GKE:
- GCE VMs will always have a metadata server to poll this info
- GKE VMS will have environment variables preset.
Returns:
A string representing the current TPU pod type, e.g.
v4-16.
"""
# Start with GKE-based check
accelerator_type = os.getenv(GKE_TPU_ACCELERATOR_TYPE_ENV_VAR, "")
if not accelerator_type:
# GCE-based VM check
accelerator_type = _get_tpu_metadata(key=GCE_TPU_ACCELERATOR_KEY)
if accelerator_type and TPUAcceleratorManager.is_valid_tpu_accelerator_type(
tpu_accelerator_type=accelerator_type
):
if accelerator_type.lower().startswith("tpu"):
return "v" + accelerator_type.lower()[3:]
return accelerator_type
logging.debug("Failed to get a valid accelerator type.")
return None
@staticmethod
def get_current_node_tpu_name() -> Optional[str]:
"""Return the name of the TPU pod that this worker node is a part of.
For instance, if the TPU was created with name "my-tpu", this function
will return "my-tpu".
If created through the Ray cluster launcher, the
name will typically be something like "ray-my-tpu-cluster-worker-aa946781-tpu".
In case the TPU was created through KubeRay, we currently expect that the
environment variable TPU_NAME is set per TPU pod slice, in which case
this function will return the value of that environment variable.
"""
try:
# Start with GKE-based check
tpu_name = os.getenv(GKE_TPU_NAME_ENV_VAR, None)
if not tpu_name:
# GCE-based VM check
tpu_name = _get_tpu_metadata(key=GCE_TPU_INSTANCE_ID_KEY)
return tpu_name
except ValueError as e:
logging.debug("Could not get TPU name: %s", e)
return None
@staticmethod
def get_current_node_tpu_worker_id() -> Optional[int]:
"""Return the worker index of the TPU pod."""
try:
# Start with GKE-based check
worker_id = os.getenv(GKE_TPU_WORKER_ID_ENV_VAR, None)
if not worker_id:
# GCE-based VM check
worker_id = _get_tpu_metadata(key=GCE_TPU_WORKER_ID_KEY)
if worker_id:
return int(worker_id)
else:
return None
except ValueError as e:
logging.debug("Could not get TPU worker id: %s", e)
return None
@staticmethod
def get_num_workers_in_current_tpu_pod() -> Optional[int]:
"""Return the total number of workers in a TPU pod."""
tpu_pod_type = TPUAcceleratorManager.get_current_node_tpu_pod_type()
chips_per_host = TPUAcceleratorManager.get_current_node_num_accelerators()
cores_per_chip = get_tpu_cores_per_chip(tpu_pod_type) # Hard-coded map.
cores_per_host = chips_per_host * cores_per_chip
if tpu_pod_type and cores_per_host > 0:
num_cores = int(tpu_pod_type.split("-")[1])
num_workers = num_cores // cores_per_host
# If the chip count doesn't fill a full host, a sub-host is still treated as a host.
if num_cores % cores_per_host != 0:
num_workers += 1
return num_workers
else:
logging.debug("Could not get num workers in TPU pod.")
return None
@staticmethod
def get_current_node_tpu_topology() -> Optional[str]:
try:
# Attempt GKE based lookup first
if topology := os.environ.get(GKE_TPU_TOPOLOGY_ENV_VAR):
return topology.strip().lower()
# GCE-based VM check using TPU env string.
tpu_env = _get_tpu_metadata(key=GCE_TPU_ENV_KEY)
if tpu_env:
topology = re.search(r"TOPOLOGY:\s*'([^']+)'", tpu_env)
if topology:
return topology.group(1).strip().lower()
except ValueError as e:
logging.debug("Could not get TPU topology: %s", e)
return None
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Attempt to detect the TPU accelerator type.
The output of this function will return the "ray accelerator type"
resource (e.g. TPU-V4) that indicates the TPU version.
We also expect that our TPU nodes contain a "TPU pod type"
resource, which indicates information about the topology of
the TPU pod slice.
We expect that the "TPU pod type" resource to be used when
running multi host workers, i.e. when TPU units are pod slices.
We expect that the "ray accelerator type" resource to be used when
running single host workers, i.e. when TPU units are single hosts.
Returns:
A string representing the TPU accelerator type,
e.g. "TPU-V2", "TPU-V3", "TPU-V4" if applicable, else None.
"""
def tpu_pod_type_to_ray_accelerator_type(
tpu_pod_type: str,
) -> Optional[str]:
return "TPU-" + str(tpu_pod_type.split("-")[0].upper())
ray_accelerator_type = None
tpu_pod_type = TPUAcceleratorManager.get_current_node_tpu_pod_type()
if tpu_pod_type is not None:
ray_accelerator_type = tpu_pod_type_to_ray_accelerator_type(
tpu_pod_type=tpu_pod_type
)
if ray_accelerator_type is None:
logger.info(
"While trying to autodetect a TPU type, "
f"received malformed accelerator_type: {tpu_pod_type}"
)
if ray_accelerator_type is None:
logging.info("Failed to auto-detect TPU type.")
return ray_accelerator_type
@staticmethod
def get_current_node_additional_resources() -> Optional[Dict[str, float]]:
"""Get additional resources required for TPU nodes.
This will populate the TPU pod type and the TPU name which
is used for TPU pod execution.
When running workloads on a TPU pod, we need a way to run
the same binary on every worker in the TPU pod.
See https://jax.readthedocs.io/en/latest/multi_process.html
for more information.
To do this in ray, we take advantage of custom resources. We
mark worker 0 of the TPU pod as a "coordinator" that identifies
the other workers in the TPU pod. We therefore need:
- worker 0 to be targetable.
- all workers in the TPU pod to have a unique identifier consistent
within a TPU pod.
So assuming we want to run the following workload:
@ray.remote
def my_jax_fn():
import jax
return jax.device_count()
We could broadcast this on a TPU pod (e.g. a v4-16) as follows:
@ray.remote(resources={"TPU-v4-16-head"})
def run_jax_fn(executable):
# Note this will execute on worker 0
tpu_name = ray.util.tpu.get_current_pod_name()
num_hosts = ray.util.tpu.get_current_pod_worker_count()
tpu_executable = executable.options(resources={"TPU": 4, tpu_name: 1})
return [tpu_executable.remote() for _ in range(num_hosts)]
Returns:
A dictionary representing additional resources that may be
necessary for a particular accelerator type.
"""
resources = {}
tpu_name = TPUAcceleratorManager.get_current_node_tpu_name()
worker_id = TPUAcceleratorManager.get_current_node_tpu_worker_id()
tpu_pod_type = TPUAcceleratorManager.get_current_node_tpu_pod_type()
if tpu_name and worker_id is not None and tpu_pod_type:
pod_head_resource_name = f"TPU-{tpu_pod_type}-head"
# Add the name of the TPU to the resource.
resources[tpu_name] = 1
# Only add in the TPU pod type resource to worker 0.
if worker_id == 0:
resources[pod_head_resource_name] = 1
else:
logging.info(
"Failed to configure TPU pod. Got: "
"tpu_name: %s, worker_id: %s, accelerator_type: %s",
tpu_name,
worker_id,
tpu_pod_type,
)
if resources:
return resources
return None
@staticmethod
def get_current_node_accelerator_labels() -> Dict[str, str]:
"""Get default TPU-specific Ray node labels for the current node.
For TPUs, these labels include:
- ray.io/tpu-slice-name: the name of the TPU Pod or slice
- ray.io/tpu-worker-id: the integer worker ID within the slice
- ray.io/tpu-topology: the TPU topology (e.g. 4x4)
- ray.io/tpu-pod-type: the TPU pod type (e.g. v4-8)
Returns:
A dictionary of TPU label keys and resolved values.
"""
tpu_labels = {}
tpu_name = TPUAcceleratorManager.get_current_node_tpu_name()
if tpu_name:
tpu_labels[ray._raylet.RAY_NODE_TPU_SLICE_NAME_KEY] = tpu_name
worker_id = TPUAcceleratorManager.get_current_node_tpu_worker_id()
if worker_id is not None:
tpu_labels[ray._raylet.RAY_NODE_TPU_WORKER_ID_KEY] = str(worker_id)
tpu_topology = TPUAcceleratorManager.get_current_node_tpu_topology()
if tpu_topology:
tpu_labels[ray._raylet.RAY_NODE_TPU_TOPOLOGY_KEY] = tpu_topology
pod_type = TPUAcceleratorManager.get_current_node_tpu_pod_type()
if pod_type:
tpu_labels[ray._raylet.RAY_NODE_TPU_POD_TYPE_KEY] = pod_type
return tpu_labels
+798
View File
@@ -0,0 +1,798 @@
# arrow_serialization.py must resides outside of ray.data, otherwise
# it causes circular dependency issues for AsyncActors due to
# ray.data's lazy import.
# see https://github.com/ray-project/ray/issues/30498 for more context.
import logging
import os
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
from ray._private.utils import is_in_test
if TYPE_CHECKING:
import pyarrow
RAY_DISABLE_CUSTOM_ARROW_JSON_OPTIONS_SERIALIZATION = (
"RAY_DISABLE_CUSTOM_ARROW_JSON_OPTIONS_SERIALIZATION"
)
RAY_DISABLE_CUSTOM_ARROW_DATA_SERIALIZATION = (
"RAY_DISABLE_CUSTOM_ARROW_DATA_SERIALIZATION"
)
logger = logging.getLogger(__name__)
# Whether we have already warned the user about bloated fallback serialization.
_serialization_fallback_set = set()
def _register_custom_datasets_serializers(serialization_context):
try:
import pyarrow as pa # noqa: F401
except ModuleNotFoundError:
# No pyarrow installed so not using Arrow, so no need for custom serializers.
return
# Register all custom serializers required by Datasets.
_register_arrow_data_serializer(serialization_context)
_register_arrow_json_readoptions_serializer(serialization_context)
_register_arrow_json_parseoptions_serializer(serialization_context)
# Register custom Arrow JSON ReadOptions serializer to workaround it not being picklable
# in Arrow < 8.0.0.
def _register_arrow_json_readoptions_serializer(serialization_context):
if (
os.environ.get(
RAY_DISABLE_CUSTOM_ARROW_JSON_OPTIONS_SERIALIZATION,
"0",
)
== "1"
):
return
import pyarrow.json as pajson
serialization_context._register_cloudpickle_serializer(
pajson.ReadOptions,
custom_serializer=lambda opts: (opts.use_threads, opts.block_size),
custom_deserializer=lambda args: pajson.ReadOptions(*args),
)
def _register_arrow_json_parseoptions_serializer(serialization_context):
if (
os.environ.get(
RAY_DISABLE_CUSTOM_ARROW_JSON_OPTIONS_SERIALIZATION,
"0",
)
== "1"
):
return
import pyarrow.json as pajson
serialization_context._register_cloudpickle_serializer(
pajson.ParseOptions,
custom_serializer=lambda opts: (
opts.explicit_schema,
opts.newlines_in_values,
opts.unexpected_field_behavior,
),
custom_deserializer=lambda args: pajson.ParseOptions(*args),
)
# Register custom Arrow data serializer to work around zero-copy slice pickling bug.
# See https://issues.apache.org/jira/browse/ARROW-10739.
def _register_arrow_data_serializer(serialization_context):
"""Custom reducer for Arrow data that works around a zero-copy slicing pickling
bug by using the Arrow IPC format for the underlying serialization.
Background:
Arrow has both array-level slicing and buffer-level slicing; both are zero-copy,
but the former has a serialization bug where the entire buffer is serialized
instead of just the slice, while the latter's serialization works as expected
and only serializes the slice of the buffer. I.e., array-level slicing doesn't
propagate the slice down to the buffer when serializing the array.
We work around this by registering a custom cloudpickle reducers for Arrow
Tables that delegates serialization to the Arrow IPC format; thankfully, Arrow's
IPC serialization has fixed this buffer truncation bug.
See https://issues.apache.org/jira/browse/ARROW-10739.
"""
if os.environ.get(RAY_DISABLE_CUSTOM_ARROW_DATA_SERIALIZATION, "0") == "1":
return
import pyarrow as pa
serialization_context._register_cloudpickle_reducer(pa.Table, _arrow_table_reduce)
serialization_context._register_cloudpickle_reducer(pa.Schema, _arrow_schema_reduce)
def _arrow_schema_reduce(
schema: "pyarrow.Schema",
) -> Tuple[Callable[["bytes"], "pyarrow.Schema"], Tuple[bytes]]:
"""Custom reducer for Arrow Schema that uses IPC serialization for performance.
Arrow's native IPC serialization for schemas is significantly faster than
cloudpickle (10-20x for serialization, 2-3x for deserialization), making
this optimization particularly valuable for workloads with large schemas.
"""
# Use Arrow's native IPC serialization which is much faster than cloudpickle
return _restore_schema_from_ipc, (schema.serialize().to_pybytes(),)
def _restore_schema_from_ipc(buf: bytes) -> "pyarrow.Schema":
"""Restore an Arrow Schema serialized to Arrow IPC format."""
import pyarrow as pa
return pa.ipc.read_schema(pa.BufferReader(buf))
def _arrow_table_reduce(t: "pyarrow.Table"):
"""Custom reducer for Arrow Tables that works around a zero-copy slice pickling bug.
Background:
Arrow has both array-level slicing and buffer-level slicing; both are zero-copy,
but the former has a serialization bug where the entire buffer is serialized
instead of just the slice, while the latter's serialization works as expected
and only serializes the slice of the buffer. I.e., array-level slicing doesn't
propagate the slice down to the buffer when serializing the array.
All that these copy methods do is, at serialization time, take the array-level
slicing and translate them to buffer-level slicing, so only the buffer slice is
sent over the wire instead of the entire buffer.
See https://issues.apache.org/jira/browse/ARROW-10739.
"""
global _serialization_fallback_set
# Reduce the ChunkedArray columns.
reduced_columns = []
for column_name in t.column_names:
column = t[column_name]
try:
# Delegate to ChunkedArray reducer.
reduced_column = _arrow_chunked_array_reduce(column)
except Exception as e:
if not _is_dense_union(column.type) and is_in_test():
# If running in a test and the column is not a dense union array
# (which we expect to need a fallback), we want to raise the error,
# not fall back.
raise e from None
if type(column.type) not in _serialization_fallback_set:
logger.warning(
"Failed to complete optimized serialization of Arrow Table, "
f"serialization of column '{column_name}' of type {column.type} "
"failed, so we're falling back to Arrow IPC serialization for the "
"table. Note that this may result in slower serialization and more "
"worker memory utilization. Serialization error:",
exc_info=True,
)
_serialization_fallback_set.add(type(column.type))
# Fall back to Arrow IPC-based workaround for the entire table.
return _arrow_table_ipc_reduce(t)
else:
# Column reducer succeeded, add reduced column to list.
reduced_columns.append(reduced_column)
return _reconstruct_table, (reduced_columns, t.schema)
def _reconstruct_table(
reduced_columns: List[Tuple[List["pyarrow.Array"], "pyarrow.DataType"]],
schema: "pyarrow.Schema",
) -> "pyarrow.Table":
"""Restore a serialized Arrow Table, reconstructing each reduced column."""
import pyarrow as pa
# Reconstruct each reduced column.
columns = []
for chunks_payload, type_ in reduced_columns:
columns.append(_reconstruct_chunked_array(chunks_payload, type_))
return pa.Table.from_arrays(columns, schema=schema)
def _arrow_chunked_array_reduce(
ca: "pyarrow.ChunkedArray",
) -> Tuple[List["PicklableArrayPayload"], "pyarrow.DataType"]:
"""Custom reducer for Arrow ChunkedArrays that works around a zero-copy slice
pickling bug. This reducer does not return a reconstruction function, since it's
expected to be reconstructed by the Arrow Table reconstructor.
"""
# Convert chunks to serialization payloads.
chunk_payloads = []
for chunk in ca.chunks:
chunk_payload = PicklableArrayPayload.from_array(chunk)
chunk_payloads.append(chunk_payload)
return chunk_payloads, ca.type
def _reconstruct_chunked_array(
chunks: List["PicklableArrayPayload"], type_: "pyarrow.DataType"
) -> "pyarrow.ChunkedArray":
"""Restore a serialized Arrow ChunkedArray from chunks and type."""
import pyarrow as pa
# Reconstruct chunks from serialization payloads.
chunks = [chunk.to_array() for chunk in chunks]
return pa.chunked_array(chunks, type_)
@dataclass
class PicklableArrayPayload:
"""Picklable array payload, holding data buffers and array metadata.
This is a helper container for pickling and reconstructing nested Arrow Arrays while
ensuring that the buffers that underly zero-copy slice views are properly truncated.
"""
# Array type.
type: "pyarrow.DataType"
# Length of array.
length: int
# Underlying data buffers.
buffers: List["pyarrow.Buffer"]
# Cached null count.
null_count: int
# Slice offset into base array.
offset: int
# Serialized array payloads for nested (child) arrays.
children: List["PicklableArrayPayload"]
@classmethod
def from_array(self, a: "pyarrow.Array") -> "PicklableArrayPayload":
"""Create a picklable array payload from an Arrow Array.
This will recursively accumulate data buffer and metadata payloads that are
ready for pickling; namely, the data buffers underlying zero-copy slice views
will be properly truncated.
"""
return _array_to_array_payload(a)
def to_array(self) -> "pyarrow.Array":
"""Reconstruct an Arrow Array from this picklable payload."""
return _array_payload_to_array(self)
def _array_payload_to_array(payload: "PicklableArrayPayload") -> "pyarrow.Array":
"""Reconstruct an Arrow Array from a possibly nested PicklableArrayPayload."""
import pyarrow as pa
children = [child_payload.to_array() for child_payload in payload.children]
if pa.types.is_dictionary(payload.type):
# Dedicated path for reconstructing a DictionaryArray, since
# Array.from_buffers() doesn't work for DictionaryArrays.
assert len(children) == 2, len(children)
indices, dictionary = children
return pa.DictionaryArray.from_arrays(
indices,
dictionary,
ordered=payload.type.ordered, # Explicitly pass the ordered flag to from_arrays() to prevent dropping it as ordered=False by default
)
elif pa.types.is_map(payload.type) and len(children) > 1:
# In pyarrow<7.0.0, the underlying map child array is not exposed, so we work
# with the key and item arrays.
assert len(children) == 3, len(children)
offsets, keys, items = children
return pa.MapArray.from_arrays(offsets, keys, items)
elif isinstance(payload.type, pa.BaseExtensionType):
assert len(children) == 1, len(children)
storage = children[0]
return payload.type.wrap_array(storage)
else:
# Common case: use Array.from_buffers() to construct an array of a certain type.
return pa.Array.from_buffers(
type=payload.type,
length=payload.length,
buffers=payload.buffers,
null_count=payload.null_count,
offset=payload.offset,
children=children,
)
def _array_to_array_payload(a: "pyarrow.Array") -> "PicklableArrayPayload":
"""Serialize an Arrow Array to an PicklableArrayPayload for later pickling.
This function's primary purpose is to dispatch to the handler for the input array
type.
"""
import pyarrow as pa
if _is_dense_union(a.type):
# Dense unions are not supported.
# TODO(Clark): Support dense unions.
raise NotImplementedError(
"Custom slice view serialization of dense union arrays is not yet "
"supported."
)
# Dispatch to handler for array type.
if pa.types.is_null(a.type):
return _null_array_to_array_payload(a)
elif _is_primitive(a.type):
return _primitive_array_to_array_payload(a)
elif _is_binary(a.type):
return _binary_array_to_array_payload(a)
elif pa.types.is_list(a.type) or pa.types.is_large_list(a.type):
return _list_array_to_array_payload(a)
elif pa.types.is_fixed_size_list(a.type):
return _fixed_size_list_array_to_array_payload(a)
elif pa.types.is_struct(a.type):
return _struct_array_to_array_payload(a)
elif pa.types.is_union(a.type):
return _union_array_to_array_payload(a)
elif pa.types.is_dictionary(a.type):
return _dictionary_array_to_array_payload(a)
elif pa.types.is_map(a.type):
return _map_array_to_array_payload(a)
elif isinstance(a.type, pa.BaseExtensionType):
return _extension_array_to_array_payload(a)
else:
raise ValueError("Unhandled Arrow array type:", a.type)
def _is_primitive(type_: "pyarrow.DataType") -> bool:
"""Whether the provided Array type is primitive (boolean, numeric, temporal or
fixed-size binary)."""
import pyarrow as pa
return (
pa.types.is_integer(type_)
or pa.types.is_floating(type_)
or pa.types.is_decimal(type_)
or pa.types.is_boolean(type_)
or pa.types.is_temporal(type_)
or pa.types.is_fixed_size_binary(type_)
)
def _is_binary(type_: "pyarrow.DataType") -> bool:
"""Whether the provided Array type is a variable-sized binary type."""
import pyarrow as pa
return (
pa.types.is_string(type_)
or pa.types.is_large_string(type_)
or pa.types.is_binary(type_)
or pa.types.is_large_binary(type_)
)
def _null_array_to_array_payload(a: "pyarrow.NullArray") -> "PicklableArrayPayload":
"""Serialize null array to PicklableArrayPayload."""
# Buffer scheme: [None]
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[None], # Single null buffer is expected.
null_count=a.null_count,
offset=0,
children=[],
)
def _primitive_array_to_array_payload(a: "pyarrow.Array") -> "PicklableArrayPayload":
"""Serialize primitive (numeric, temporal, boolean) arrays to
PicklableArrayPayload.
"""
assert _is_primitive(a.type), a.type
# Buffer scheme: [bitmap, data]
buffers = a.buffers()
assert len(buffers) == 2, len(buffers)
# Copy bitmap buffer, if needed.
bitmap_buf = buffers[0]
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(bitmap_buf, a.offset, len(a))
else:
bitmap_buf = None
# Copy data buffer, if needed.
data_buf = buffers[1]
if data_buf is not None:
data_buf = _copy_buffer_if_needed(buffers[1], a.type, a.offset, len(a))
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf, data_buf],
null_count=a.null_count,
offset=0,
children=[],
)
def _binary_array_to_array_payload(a: "pyarrow.Array") -> "PicklableArrayPayload":
"""Serialize binary (variable-sized binary, string) arrays to
PicklableArrayPayload.
"""
assert _is_binary(a.type), a.type
# Buffer scheme: [bitmap, value_offsets, data]
buffers = a.buffers()
assert len(buffers) == 3, len(buffers)
# Copy bitmap buffer, if needed.
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(buffers[0], a.offset, len(a))
else:
bitmap_buf = None
# Copy offset buffer, if needed.
offset_buf = buffers[1]
offset_buf, data_offset, data_length = _copy_offsets_buffer_if_needed(
offset_buf, a.type, a.offset, len(a)
)
data_buf = buffers[2]
data_buf = _copy_buffer_if_needed(data_buf, None, data_offset, data_length)
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf, offset_buf, data_buf],
null_count=a.null_count,
offset=0,
children=[],
)
def _list_array_to_array_payload(a: "pyarrow.Array") -> "PicklableArrayPayload":
"""Serialize list (regular and large) arrays to PicklableArrayPayload."""
# Dedicated path for ListArrays. These arrays have a nested set of bitmap and
# offset buffers, eventually bottoming out on a data buffer.
# Buffer scheme:
# [bitmap, offsets, bitmap, offsets, ..., bitmap, data]
buffers = a.buffers()
assert len(buffers) > 1, len(buffers)
# Copy bitmap buffer, if needed.
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(buffers[0], a.offset, len(a))
else:
bitmap_buf = None
# Copy offset buffer, if needed.
offset_buf = buffers[1]
offset_buf, child_offset, child_length = _copy_offsets_buffer_if_needed(
offset_buf, a.type, a.offset, len(a)
)
# Propagate slice to child.
child = a.values.slice(child_offset, child_length)
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf, offset_buf],
null_count=a.null_count,
offset=0,
children=[_array_to_array_payload(child)],
)
def _fixed_size_list_array_to_array_payload(
a: "pyarrow.FixedSizeListArray",
) -> "PicklableArrayPayload":
"""Serialize fixed size list arrays to PicklableArrayPayload."""
# Dedicated path for fixed-size lists.
# Buffer scheme:
# [bitmap, values_bitmap, values_data, values_subbuffers...]
buffers = a.buffers()
assert len(buffers) >= 1, len(buffers)
# Copy bitmap buffer, if needed.
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(buffers[0], a.offset, len(a))
else:
bitmap_buf = None
# Propagate slice to child.
child_offset = a.type.list_size * a.offset
child_length = a.type.list_size * len(a)
child = a.values.slice(child_offset, child_length)
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf],
null_count=a.null_count,
offset=0,
children=[_array_to_array_payload(child)],
)
def _struct_array_to_array_payload(a: "pyarrow.StructArray") -> "PicklableArrayPayload":
"""Serialize struct arrays to PicklableArrayPayload."""
# Dedicated path for StructArrays.
# StructArrays have a top-level bitmap buffer and one or more children arrays.
# Buffer scheme: [bitmap, None, child_bitmap, child_data, ...]
buffers = a.buffers()
assert len(buffers) >= 1, len(buffers)
# Copy bitmap buffer, if needed.
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(buffers[0], a.offset, len(a))
else:
bitmap_buf = None
# Get field children payload.
# Offsets and truncations are already propagated to the field arrays, so we can
# serialize them as-is.
children = [_array_to_array_payload(a.field(i)) for i in range(a.type.num_fields)]
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf],
null_count=a.null_count,
offset=0,
children=children,
)
def _union_array_to_array_payload(a: "pyarrow.UnionArray") -> "PicklableArrayPayload":
"""Serialize union arrays to PicklableArrayPayload."""
import pyarrow as pa
# Dedicated path for UnionArrays.
# UnionArrays have a top-level bitmap buffer and type code buffer, and one or
# more children arrays.
# Buffer scheme: [None, typecodes, child_bitmap, child_data, ...]
assert not _is_dense_union(a.type)
buffers = a.buffers()
assert len(buffers) > 1, len(buffers)
bitmap_buf = buffers[0]
assert bitmap_buf is None, bitmap_buf
# Copy type code buffer, if needed.
type_code_buf = buffers[1]
type_code_buf = _copy_buffer_if_needed(type_code_buf, pa.int8(), a.offset, len(a))
# Get field children payload.
# Offsets and truncations are already propagated to the field arrays, so we can
# serialize them as-is.
children = [_array_to_array_payload(a.field(i)) for i in range(a.type.num_fields)]
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[bitmap_buf, type_code_buf],
null_count=a.null_count,
offset=0,
children=children,
)
def _dictionary_array_to_array_payload(
a: "pyarrow.DictionaryArray",
) -> "PicklableArrayPayload":
"""Serialize dictionary arrays to PicklableArrayPayload."""
# Dedicated path for DictionaryArrays.
# Buffer scheme: [indices_bitmap, indices_data] (dictionary stored separately)
indices_payload = _array_to_array_payload(a.indices)
dictionary_payload = _array_to_array_payload(a.dictionary)
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[],
null_count=a.null_count,
offset=0,
children=[indices_payload, dictionary_payload],
)
def _map_array_to_array_payload(a: "pyarrow.MapArray") -> "PicklableArrayPayload":
"""Serialize map arrays to PicklableArrayPayload."""
import pyarrow as pa
# Dedicated path for MapArrays.
# Buffer scheme: [bitmap, offsets, child_struct_array_buffers, ...]
buffers = a.buffers()
assert len(buffers) > 0, len(buffers)
# Copy bitmap buffer, if needed.
if a.null_count > 0:
bitmap_buf = _copy_bitpacked_buffer_if_needed(buffers[0], a.offset, len(a))
else:
bitmap_buf = None
new_buffers = [bitmap_buf]
# Copy offsets buffer, if needed.
offset_buf = buffers[1]
offset_buf, data_offset, data_length = _copy_offsets_buffer_if_needed(
offset_buf, a.type, a.offset, len(a)
)
if isinstance(a, pa.lib.ListArray):
# Map arrays directly expose the one child struct array in pyarrow>=7.0.0, which
# is easier to work with than the raw buffers.
new_buffers.append(offset_buf)
children = [_array_to_array_payload(a.values.slice(data_offset, data_length))]
else:
# In pyarrow<7.0.0, the child struct array is not exposed, so we work with the
# key and item arrays.
buffers = a.buffers()
assert len(buffers) > 2, len(buffers)
# Reconstruct offsets array.
offsets = pa.Array.from_buffers(
pa.int32(), len(a) + 1, [bitmap_buf, offset_buf]
)
# Propagate slice to keys.
keys = a.keys.slice(data_offset, data_length)
# Propagate slice to items.
items = a.items.slice(data_offset, data_length)
children = [
_array_to_array_payload(offsets),
_array_to_array_payload(keys),
_array_to_array_payload(items),
]
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=new_buffers,
null_count=a.null_count,
offset=0,
children=children,
)
def _extension_array_to_array_payload(
a: "pyarrow.ExtensionArray",
) -> "PicklableArrayPayload":
storage_payload = _array_to_array_payload(a.storage)
return PicklableArrayPayload(
type=a.type,
length=len(a),
buffers=[],
null_count=a.null_count,
offset=0,
children=[storage_payload],
)
def _copy_buffer_if_needed(
buf: "pyarrow.Buffer",
type_: Optional["pyarrow.DataType"],
offset: int,
length: int,
) -> "pyarrow.Buffer":
"""Copy buffer, if needed."""
import pyarrow as pa
if type_ is not None and pa.types.is_boolean(type_):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buf = _copy_bitpacked_buffer_if_needed(buf, offset, length)
else:
type_bytewidth = type_.bit_width // 8 if type_ is not None else 1
buf = _copy_normal_buffer_if_needed(buf, type_bytewidth, offset, length)
return buf
def _copy_normal_buffer_if_needed(
buf: "pyarrow.Buffer",
byte_width: int,
offset: int,
length: int,
) -> "pyarrow.Buffer":
"""Copy buffer, if needed."""
byte_offset = offset * byte_width
byte_length = length * byte_width
if offset > 0 or byte_length < buf.size:
# Array is a zero-copy slice, so we need to copy to a new buffer before
# serializing; this slice of the underlying buffer (not the array) will ensure
# that the buffer is properly copied at pickle-time.
buf = buf.slice(byte_offset, byte_length)
return buf
def _copy_bitpacked_buffer_if_needed(
buf: "pyarrow.Buffer",
offset: int,
length: int,
) -> "pyarrow.Buffer":
"""Copy bit-packed binary buffer, if needed."""
bit_offset = offset % 8
byte_offset = offset // 8
byte_length = _bytes_for_bits(bit_offset + length) // 8
if offset > 0 or byte_length < buf.size:
buf = buf.slice(byte_offset, byte_length)
if bit_offset != 0:
# Need to manually shift the buffer to eliminate the bit offset.
buf = _align_bit_offset(buf, bit_offset, byte_length)
return buf
def _copy_offsets_buffer_if_needed(
buf: "pyarrow.Buffer",
arr_type: "pyarrow.DataType",
offset: int,
length: int,
) -> Tuple["pyarrow.Buffer", int, int]:
"""Copy the provided offsets buffer, returning the copied buffer and the
offset + length of the underlying data.
"""
import pyarrow as pa
import pyarrow.compute as pac
if (
pa.types.is_large_list(arr_type)
or pa.types.is_large_string(arr_type)
or pa.types.is_large_binary(arr_type)
or pa.types.is_large_unicode(arr_type)
):
offset_type = pa.int64()
else:
offset_type = pa.int32()
# Copy offset buffer, if needed.
buf = _copy_buffer_if_needed(buf, offset_type, offset, length + 1)
# Reconstruct the offset array so we can determine the offset and length
# of the child array.
offsets = pa.Array.from_buffers(offset_type, length + 1, [None, buf])
child_offset = offsets[0].as_py()
child_length = offsets[-1].as_py() - child_offset
# Create new offsets aligned to 0 for the copied data buffer slice.
offsets = pac.subtract(offsets, child_offset)
if pa.types.is_int32(offset_type):
# We need to cast the resulting Int64Array back down to an Int32Array.
offsets = offsets.cast(offset_type, safe=False)
buf = offsets.buffers()[1]
return buf, child_offset, child_length
def _bytes_for_bits(n: int) -> int:
"""Round up n to the nearest multiple of 8.
This is used to get the byte-padded number of bits for n bits.
"""
return (n + 7) & (-8)
def _align_bit_offset(
buf: "pyarrow.Buffer",
bit_offset: int,
byte_length: int,
) -> "pyarrow.Buffer":
"""Align the bit offset into the buffer with the front of the buffer by shifting
the buffer and eliminating the offset.
"""
import pyarrow as pa
bytes_ = buf.to_pybytes()
bytes_as_int = int.from_bytes(bytes_, sys.byteorder)
bytes_as_int >>= bit_offset
bytes_ = bytes_as_int.to_bytes(byte_length, sys.byteorder)
return pa.py_buffer(bytes_)
def _arrow_table_ipc_reduce(table: "pyarrow.Table"):
"""Custom reducer for Arrow Table that works around a zero-copy slicing pickling
bug by using the Arrow IPC format for the underlying serialization.
This is currently used as a fallback for unsupported types (or unknown bugs) for
the manual buffer truncation workaround, e.g. for dense unions.
"""
from pyarrow.ipc import RecordBatchStreamWriter
from pyarrow.lib import BufferOutputStream
output_stream = BufferOutputStream()
with RecordBatchStreamWriter(output_stream, schema=table.schema) as wr:
wr.write_table(table)
# NOTE: output_stream.getvalue() materializes the serialized table to a single
# contiguous bytestring, resulting in a few copy. This adds 1-2 extra copies on the
# serialization side, and 1 extra copy on the deserialization side.
return _restore_table_from_ipc, (output_stream.getvalue(),)
def _restore_table_from_ipc(buf: bytes) -> "pyarrow.Table":
"""Restore an Arrow Table serialized to Arrow IPC format."""
from pyarrow.ipc import RecordBatchStreamReader
with RecordBatchStreamReader(buf) as reader:
return reader.read_all()
def _is_dense_union(type_: "pyarrow.DataType") -> bool:
"""Whether the provided Arrow type is a dense union."""
import pyarrow as pa
return pa.types.is_union(type_) and type_.mode == "dense"
+54
View File
@@ -0,0 +1,54 @@
"""
This file should only be imported from Python 3.
It will raise SyntaxError when importing from Python 2.
"""
import asyncio
import inspect
from functools import lru_cache
from ray._private.ray_constants import env_bool
try:
import uvloop
except ImportError:
uvloop = None
def get_new_event_loop():
"""Construct a new event loop. Ray will use uvloop if it exists and is enabled"""
if uvloop and env_bool("RAY_USE_UVLOOP", True):
return uvloop.new_event_loop()
else:
return asyncio.new_event_loop()
def try_install_uvloop():
"""Installs uvloop as event-loop implementation for asyncio (if available and enabled)"""
if uvloop and env_bool("RAY_USE_UVLOOP", True):
uvloop.install()
else:
pass
def is_async_func(func) -> bool:
"""Return True if the function is an async or async generator method."""
return inspect.iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
@lru_cache(maxsize=2**10)
def has_async_methods(cls: object) -> bool:
"""Return True if the class has any async methods."""
return len(inspect.getmembers(cls, predicate=is_async_func)) > 0
@lru_cache(maxsize=2**10)
def sync_to_async(func):
"""Wrap a blocking function in an async function"""
if is_async_func(func):
return func
async def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
+65
View File
@@ -0,0 +1,65 @@
# Adapted from [aiodebug](https://gitlab.com/quantlane/libs/aiodebug)
# Copyright 2016-2022 Quantlane s.r.o.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Modifications:
# - Removed the dependency to `logwood`.
# - Renamed `monitor_loop_lag.enable()` to just `enable_monitor_loop_lag()`.
# - Miscellaneous changes to make it work with Ray.
import asyncio
import asyncio.events
from typing import Callable, Optional, Set
# Strong references to background tasks to prevent them from being garbage
# collected mid-execution. The asyncio event loop only keeps weak references
# to tasks, so a task with no other strong references can be collected at
# any time. See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task
_BACKGROUND_TASKS: Set[asyncio.Task] = set()
def enable_monitor_loop_lag(
callback: Callable[[float], None],
interval_s: float = 0.25,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
"""Start logging event loop lags to the callback.
In ideal circumstances they should be very close to zero. Lags may increase
if event loop callbacks block for too long.
Note: this works for all event loops, including uvloop.
Args:
callback: Callback to call with the lag in seconds.
interval_s: How often to measure the lag, in seconds.
loop: The event loop to monitor. If None, uses the running loop.
"""
if loop is None:
loop = asyncio.get_running_loop()
if loop is None:
raise ValueError("No provided loop, nor running loop found.")
async def monitor():
while loop.is_running():
t0 = loop.time()
await asyncio.sleep(interval_s)
lag = loop.time() - t0 - interval_s # Should be close to zero.
callback(lag)
task = loop.create_task(monitor(), name="async_utils.monitor_loop_lag")
# Keep a strong reference so the task isn't garbage collected mid-execution.
_BACKGROUND_TASKS.add(task)
task.add_done_callback(_BACKGROUND_TASKS.discard)
@@ -0,0 +1,14 @@
# Authentication error messages
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE = (
"Token authentication is enabled but no authentication token was found"
)
TOKEN_INVALID_ERROR_MESSAGE = "Token authentication is enabled but the authentication token is invalid or incorrect." # noqa: E501
# HTTP header and cookie constants
AUTHORIZATION_HEADER_NAME = "authorization"
AUTHORIZATION_BEARER_PREFIX = "Bearer "
RAY_AUTHORIZATION_HEADER_NAME = "x-ray-authorization"
AUTHENTICATION_TOKEN_COOKIE_NAME = "ray-authentication-token"
AUTHENTICATION_TOKEN_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
@@ -0,0 +1,9 @@
import secrets
def generate_new_authentication_token() -> str:
"""Generate an authentication token for the cluster.
256 bits of entropy is considered sufficient to be durable to brute force attacks.
"""
return secrets.token_hex(32)
@@ -0,0 +1,103 @@
"""Authentication token setup for Ray.
This module provides functions to generate and save authentication tokens
for Ray's token-based authentication system. Token loading and caching is
handled by the C++ AuthenticationTokenLoader.
"""
import logging
from pathlib import Path
from typing import Any, Dict, Optional
from ray._private.authentication.authentication_constants import (
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE,
)
from ray._private.authentication.authentication_token_generator import (
generate_new_authentication_token,
)
from ray._raylet import (
AuthenticationMode,
AuthenticationTokenLoader,
get_authentication_mode,
)
from ray.exceptions import AuthenticationError
logger = logging.getLogger(__name__)
def generate_and_save_token() -> None:
"""Generate a new random token and save it in the default token path.
Returns:
The newly generated authentication token.
"""
# Generate a UUID-based token
token = generate_new_authentication_token()
token_path = _get_default_token_path()
try:
# Create directory if it doesn't exist
token_path.parent.mkdir(parents=True, exist_ok=True)
# Write token to file with explicit flush and fsync
with open(token_path, "w") as f:
f.write(token)
logger.info(f"Generated new authentication token and saved to {token_path}")
except Exception:
raise
def _get_default_token_path() -> Path:
"""Get the default token file path (~/.ray/auth_token).
Returns:
Path object pointing to ~/.ray/auth_token
"""
return Path.home() / ".ray" / "auth_token"
def ensure_token_if_auth_enabled(
system_config: Optional[Dict[str, Any]] = None, create_token_if_missing: bool = True
) -> None:
"""Check authentication settings and set up token resources if authentication is enabled.
Ray calls this early during ray.init() to do the following for token-based authentication:
1. Check whether you enabled token-based authentication.
2. Make sure a token is available if authentication is enabled.
3. Generate and save a default token for new local clusters if one doesn't already exist.
Args:
system_config: Ray raises an error if you set AUTH_MODE in system_config instead of the environment.
create_token_if_missing: Generate a new token if one doesn't already exist.
Raises:
RuntimeError: Ray raises this error if authentication is enabled but no token is found when connecting
to an existing cluster.
"""
# Check if you enabled token authentication.
if get_authentication_mode() != AuthenticationMode.TOKEN:
if (
system_config
and "AUTH_MODE" in system_config
and system_config["AUTH_MODE"] != "disabled"
):
raise RuntimeError(
"Set authentication mode can only be set with the `RAY_AUTH_MODE` environment variable, not using the system_config."
)
return
token_loader = AuthenticationTokenLoader.instance()
if not token_loader.has_token(ignore_auth_mode=True):
if create_token_if_missing:
# Generate a new token.
generate_and_save_token()
# Reload the cache so subsequent calls to token_loader read the new token.
token_loader.reset_cache()
else:
raise AuthenticationError(
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE
)
@@ -0,0 +1,58 @@
try:
from ray._raylet import (
AuthenticationMode,
get_authentication_mode,
validate_authentication_token,
)
_RAYLET_AVAILABLE = True
except ImportError:
# ray._raylet not available during doc builds
_RAYLET_AVAILABLE = False
def is_token_auth_enabled() -> bool:
"""Check if token authentication is enabled.
Returns:
bool: True if AUTH_MODE is set to "token".
"""
if not _RAYLET_AVAILABLE:
return False
return get_authentication_mode() == AuthenticationMode.TOKEN
def validate_request_token(auth_header: str) -> bool:
"""Validate the Authorization header from an HTTP request.
Args:
auth_header: The Authorization header value (e.g., "Bearer <token>")
Returns:
bool: True if token is valid, False otherwise
"""
if not _RAYLET_AVAILABLE or not auth_header:
return False
# validate_authentication_token expects full "Bearer <token>" format
# and performs equality comparison via C++ layer
return validate_authentication_token(auth_header)
def get_authentication_mode_name(mode: AuthenticationMode) -> str:
"""Convert AuthenticationMode enum value to string name.
Args:
mode: AuthenticationMode enum value from ray._raylet
Returns:
String name: "disabled", "token"
"""
from ray._raylet import AuthenticationMode
_MODE_NAMES = {
AuthenticationMode.DISABLED: "disabled",
AuthenticationMode.TOKEN: "token",
}
return _MODE_NAMES.get(mode, "unknown")
@@ -0,0 +1,155 @@
"""gRPC client interceptor for token-based authentication."""
import logging
from collections import namedtuple
from typing import Tuple
import grpc
from grpc import aio as aiogrpc
from ray._raylet import AuthenticationTokenLoader
logger = logging.getLogger(__name__)
# Named tuple to hold client call details
_ClientCallDetails = namedtuple(
"_ClientCallDetails",
("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"),
)
def _get_authentication_metadata_tuple() -> Tuple[Tuple[str, str], ...]:
"""Get gRPC metadata tuple for authentication. Currently only supported for token authentication.
Returns:
tuple: Empty tuple or ((AUTHORIZATION_HEADER_NAME, "Bearer <token>"),)
"""
token_loader = AuthenticationTokenLoader.instance()
if not token_loader.has_token():
return ()
headers = token_loader.get_token_for_http_header()
# Convert HTTP header dict to gRPC metadata tuple
# gRPC expects: (("key", "value"), ...)
return tuple((k, v) for k, v in headers.items())
class SyncAuthenticationMetadataClientInterceptor(
grpc.UnaryUnaryClientInterceptor,
grpc.UnaryStreamClientInterceptor,
grpc.StreamUnaryClientInterceptor,
grpc.StreamStreamClientInterceptor,
):
"""Synchronous gRPC client interceptor that adds authentication metadata."""
def _intercept_call_details(self, client_call_details):
"""Helper method to add authentication metadata to client call details."""
metadata = list(client_call_details.metadata or [])
metadata.extend(_get_authentication_metadata_tuple())
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=getattr(client_call_details, "wait_for_ready", None),
compression=getattr(client_call_details, "compression", None),
)
def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._intercept_call_details(client_call_details)
return continuation(new_details, request)
def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._intercept_call_details(client_call_details)
return continuation(new_details, request)
def intercept_stream_unary(
self, continuation, client_call_details, request_iterator
):
new_details = self._intercept_call_details(client_call_details)
return continuation(new_details, request_iterator)
def intercept_stream_stream(
self, continuation, client_call_details, request_iterator
):
new_details = self._intercept_call_details(client_call_details)
return continuation(new_details, request_iterator)
def _intercept_call_details_async(client_call_details):
"""Helper to add authentication metadata to client call details (async version)."""
metadata = list(client_call_details.metadata or [])
metadata.extend(_get_authentication_metadata_tuple())
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=getattr(client_call_details, "wait_for_ready", None),
compression=getattr(client_call_details, "compression", None),
)
# NOTE: gRPC aio's Channel.__init__ uses if-elif chains to categorize interceptors,
# so a single class inheriting from multiple interceptor types will only be registered
# for the first matching type. We must use separate classes for each RPC type.
# See: https://github.com/grpc/grpc/blob/master/src/python/grpcio/grpc/aio/_channel.py
class _AsyncUnaryUnaryAuthInterceptor(aiogrpc.UnaryUnaryClientInterceptor):
"""Async unary-unary interceptor that adds authentication metadata."""
async def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = _intercept_call_details_async(client_call_details)
return await continuation(new_details, request)
class _AsyncUnaryStreamAuthInterceptor(aiogrpc.UnaryStreamClientInterceptor):
"""Async unary-stream interceptor that adds authentication metadata."""
async def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = _intercept_call_details_async(client_call_details)
return await continuation(new_details, request)
class _AsyncStreamUnaryAuthInterceptor(aiogrpc.StreamUnaryClientInterceptor):
"""Async stream-unary interceptor that adds authentication metadata."""
async def intercept_stream_unary(
self, continuation, client_call_details, request_iterator
):
new_details = _intercept_call_details_async(client_call_details)
return await continuation(new_details, request_iterator)
class _AsyncStreamStreamAuthInterceptor(aiogrpc.StreamStreamClientInterceptor):
"""Async stream-stream interceptor that adds authentication metadata."""
async def intercept_stream_stream(
self, continuation, client_call_details, request_iterator
):
new_details = _intercept_call_details_async(client_call_details)
return await continuation(new_details, request_iterator)
def get_async_auth_interceptors():
"""Get a list of async authentication interceptors for all RPC types.
Returns a list of separate interceptor instances, one for each RPC type,
because gRPC aio channels only register multi-inheritance interceptors
for the first matching type.
Returns:
List of interceptor instances for unary-unary, unary-stream,
stream-unary, and stream-stream RPCs.
"""
return [
_AsyncUnaryUnaryAuthInterceptor(),
_AsyncUnaryStreamAuthInterceptor(),
_AsyncStreamUnaryAuthInterceptor(),
_AsyncStreamStreamAuthInterceptor(),
]
@@ -0,0 +1,232 @@
"""gRPC server interceptor for token-based authentication."""
import logging
from typing import Awaitable, Callable
import grpc
from grpc import aio as aiogrpc
from ray._private.authentication.authentication_constants import (
AUTHORIZATION_HEADER_NAME,
)
from ray._private.authentication.authentication_utils import (
is_token_auth_enabled,
validate_request_token,
)
logger = logging.getLogger(__name__)
def _authenticate_request(metadata: tuple) -> bool:
"""Authenticate incoming request. currently only supports token authentication.
Args:
metadata: gRPC metadata tuple of (key, value) pairs
Returns:
True if authentication succeeds or is not required, False otherwise
"""
if not is_token_auth_enabled():
return True
# Extract authorization header from metadata
auth_header = None
for key, value in metadata:
if key.lower() == AUTHORIZATION_HEADER_NAME:
auth_header = value
break
if not auth_header:
logger.warning("Authentication required but no authorization header provided")
return False
# Validate the token format and value
# validate_request_token returns bool (True if valid, False otherwise)
return validate_request_token(auth_header)
class AsyncAuthenticationServerInterceptor(aiogrpc.ServerInterceptor):
"""Async gRPC server interceptor that validates authentication tokens.
This interceptor checks the "authorization" metadata header for a valid
Bearer token when token authentication is enabled via RAY_AUTH_MODE=token.
If the token is missing or invalid, the request is rejected with UNAUTHENTICATED status.
"""
async def intercept_service(
self,
continuation: Callable[
[grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler]
],
handler_call_details: grpc.HandlerCallDetails,
) -> grpc.RpcMethodHandler:
"""Intercept service calls to validate authentication.
This method is called once per RPC to get the handler. We wrap the handler
to validate authentication before executing the actual RPC method.
"""
# Get the actual handler
handler = await continuation(handler_call_details)
if handler is None:
return None
async def _abort_if_unauthenticated(context):
"""Abort the RPC if authentication fails."""
if not _authenticate_request(context.invocation_metadata()):
await context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Invalid or missing authentication token",
)
# Wrap the RPC behavior with authentication check
def wrap_unary_response(behavior):
"""Wrap a unary response RPC method to validate authentication first."""
if behavior is None:
return None
async def wrapped(request_or_iterator, context):
await _abort_if_unauthenticated(context)
return await behavior(request_or_iterator, context)
return wrapped
def wrap_stream_response(behavior):
"""Wrap a streaming response RPC method to validate authentication first."""
if behavior is None:
return None
async def wrapped(request_or_iterator, context):
await _abort_if_unauthenticated(context)
async for response in behavior(request_or_iterator, context):
yield response
return wrapped
# Create a wrapper class that implements RpcMethodHandler interface
class AuthenticatedHandler:
"""Wrapper handler that validates authentication."""
def __init__(
self, original_handler, unary_wrapper_func, stream_wrapper_func
):
self._original = original_handler
self._wrap_unary = unary_wrapper_func
self._wrap_stream = stream_wrapper_func
@property
def request_streaming(self):
return self._original.request_streaming
@property
def response_streaming(self):
return self._original.response_streaming
@property
def request_deserializer(self):
return self._original.request_deserializer
@property
def response_serializer(self):
return self._original.response_serializer
@property
def unary_unary(self):
return self._wrap_unary(self._original.unary_unary)
@property
def unary_stream(self):
return self._wrap_stream(self._original.unary_stream)
@property
def stream_unary(self):
return self._wrap_unary(self._original.stream_unary)
@property
def stream_stream(self):
return self._wrap_stream(self._original.stream_stream)
return AuthenticatedHandler(handler, wrap_unary_response, wrap_stream_response)
class SyncAuthenticationServerInterceptor(grpc.ServerInterceptor):
"""Synchronous gRPC server interceptor that validates authentication tokens.
This interceptor checks the "authorization" metadata header for a valid
Bearer token when token authentication is enabled via RAY_AUTH_MODE=token.
If the token is missing or invalid, the request is rejected with UNAUTHENTICATED status.
"""
def intercept_service(
self,
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
handler_call_details: grpc.HandlerCallDetails,
) -> grpc.RpcMethodHandler:
"""Intercept service calls to validate authentication.
This method is called once per RPC to get the handler. We wrap the handler
to validate authentication before executing the actual RPC method.
"""
# Get the actual handler
handler = continuation(handler_call_details)
if handler is None:
return None
# Wrap the RPC behavior with authentication check
def wrap_rpc_behavior(behavior):
"""Wrap an RPC method to validate authentication first."""
if behavior is None:
return None
def wrapped(request_or_iterator, context):
if not _authenticate_request(context.invocation_metadata()):
context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Invalid or missing authentication token",
)
return behavior(request_or_iterator, context)
return wrapped
# Create a wrapper class that implements RpcMethodHandler interface
class AuthenticatedHandler:
"""Wrapper handler that validates authentication."""
def __init__(self, original_handler, wrapper_func):
self._original = original_handler
self._wrap = wrapper_func
@property
def request_streaming(self):
return self._original.request_streaming
@property
def response_streaming(self):
return self._original.response_streaming
@property
def request_deserializer(self):
return self._original.request_deserializer
@property
def response_serializer(self):
return self._original.response_serializer
@property
def unary_unary(self):
return self._wrap(self._original.unary_unary)
@property
def unary_stream(self):
return self._wrap(self._original.unary_stream)
@property
def stream_unary(self):
return self._wrap(self._original.stream_unary)
@property
def stream_stream(self):
return self._wrap(self._original.stream_stream)
return AuthenticatedHandler(handler, wrap_rpc_behavior)
@@ -0,0 +1,129 @@
import logging
from types import ModuleType
from typing import Dict, List, Optional
from ray._private.authentication import (
authentication_constants,
authentication_utils as auth_utils,
)
logger = logging.getLogger(__name__)
def get_token_auth_middleware(
aiohttp_module: ModuleType,
whitelisted_exact_paths: Optional[List[str]] = None,
whitelisted_path_prefixes: Optional[List[str]] = None,
):
"""Internal helper to create token auth middleware with provided modules.
Args:
aiohttp_module: The aiohttp module to use
whitelisted_exact_paths: List of exact paths that don't require authentication
whitelisted_path_prefixes: List of path prefixes that don't require authentication
Returns:
An aiohttp middleware function
"""
@aiohttp_module.web.middleware
async def token_auth_middleware(request, handler):
"""Middleware to validate bearer tokens when token authentication is enabled.
In minimal Ray installations (without ray._raylet), this middleware is a no-op
and passes all requests through without authentication.
"""
# No-op if token auth is not enabled or raylet is not available
if not auth_utils.is_token_auth_enabled():
return await handler(request)
# skip authentication for whitelisted paths
if (whitelisted_exact_paths and request.path in whitelisted_exact_paths) or (
whitelisted_path_prefixes
and request.path.startswith(tuple(whitelisted_path_prefixes))
):
return await handler(request)
# Try to get authentication token from multiple sources (in priority order):
# 1. Standard "Authorization" header (for API clients, SDKs)
# 2. Fallback "X-Ray-Authorization" header (for proxies and KubeRay)
# 3. Cookie (for web dashboard sessions)
auth_header = request.headers.get(
authentication_constants.AUTHORIZATION_HEADER_NAME, ""
)
if not auth_header:
auth_header = request.headers.get(
authentication_constants.RAY_AUTHORIZATION_HEADER_NAME, ""
)
if not auth_header:
token = request.cookies.get(
authentication_constants.AUTHENTICATION_TOKEN_COOKIE_NAME
)
if token:
# Format as Bearer token for validation
auth_header = (
authentication_constants.AUTHORIZATION_BEARER_PREFIX + token
)
if not auth_header:
return aiohttp_module.web.Response(
status=401, text="Unauthorized: Missing authentication token"
)
if not auth_utils.validate_request_token(auth_header):
return aiohttp_module.web.Response(
status=403, text="Forbidden: Invalid authentication token"
)
return await handler(request)
return token_auth_middleware
def get_auth_headers_if_auth_enabled(user_headers: Dict[str, str]) -> Dict[str, str]:
if not auth_utils.is_token_auth_enabled():
return {}
from ray._raylet import AuthenticationTokenLoader
# Check if user provided their own Authorization header (case-insensitive)
has_user_auth = any(
key.lower() == authentication_constants.AUTHORIZATION_HEADER_NAME
for key in user_headers.keys()
)
if has_user_auth:
# User has provided their own auth header, don't override
return {}
token_loader = AuthenticationTokenLoader.instance()
auth_headers = token_loader.get_token_for_http_header()
if not auth_headers:
# Token auth enabled but no token found
logger.warning(
"Token authentication is enabled but no token was found. "
"Requests to authenticated clusters will fail."
)
return auth_headers
def format_authentication_http_error(status: int, body: str) -> Optional[str]:
"""Return a user-friendly authentication error message, if applicable."""
if status == 401:
return "Authentication required: {body}\n\n{details}".format(
body=body,
details=authentication_constants.TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE,
)
if status == 403:
return "Authentication failed: {body}\n\n{details}".format(
body=body,
details=authentication_constants.TOKEN_INVALID_ERROR_MESSAGE,
)
return None
@@ -0,0 +1,157 @@
import os
import shutil
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional
from ray._raylet import AuthenticationTokenLoader, Config
_AUTH_ENV_VARS = ("RAY_AUTH_MODE", "RAY_AUTH_TOKEN", "RAY_AUTH_TOKEN_PATH")
_DEFAULT_AUTH_TOKEN_RELATIVE_PATH = Path(".ray") / "auth_token"
def reset_auth_token_state() -> None:
"""Reset authentication token and AUTH_MODE ray config."""
AuthenticationTokenLoader.instance().reset_cache()
Config.initialize("")
def set_auth_mode(mode: str) -> None:
"""Set the authentication mode environment variable."""
os.environ["RAY_AUTH_MODE"] = mode
def set_env_auth_token(token: str) -> None:
"""Configure the authentication token via environment variable."""
os.environ["RAY_AUTH_TOKEN"] = token
os.environ.pop("RAY_AUTH_TOKEN_PATH", None)
def set_auth_token_path(token: str, path: Path) -> None:
"""Write the authentication token to a specific path and point the loader to it."""
token_path = Path(path)
if token is not None:
token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text(token)
os.environ["RAY_AUTH_TOKEN_PATH"] = str(token_path)
os.environ.pop("RAY_AUTH_TOKEN", None)
def set_default_auth_token(token: str) -> Path:
"""Write the authentication token to the default ~/.ray/auth_token location."""
default_path = Path.home() / _DEFAULT_AUTH_TOKEN_RELATIVE_PATH
default_path.parent.mkdir(parents=True, exist_ok=True)
default_path.write_text(token)
return default_path
def clear_auth_token_sources(remove_default: bool = False) -> None:
"""Clear authentication-related environment variables and optional default token file."""
for var in ("RAY_AUTH_TOKEN", "RAY_AUTH_TOKEN_PATH"):
os.environ.pop(var, None)
if remove_default:
default_path = Path.home() / _DEFAULT_AUTH_TOKEN_RELATIVE_PATH
default_path.unlink(missing_ok=True)
@dataclass
class AuthenticationEnvSnapshot:
original_env: Dict[str, Optional[str]]
original_home: Optional[str]
home_was_set: bool
temp_home: Optional[Path]
default_token_path: Path
default_token_exists: bool
default_token_contents: Optional[str]
@classmethod
def capture(cls) -> "AuthenticationEnvSnapshot":
"""Capture current authentication-related environment state."""
original_env = {var: os.environ.get(var) for var in _AUTH_ENV_VARS}
home_was_set = "HOME" in os.environ
original_home = os.environ.get("HOME")
temp_home: Optional[Path] = None
if not home_was_set:
# in CI $HOME may not be set which can cause issues with tests related to default auth token file.
test_tmpdir = os.environ.get("TEST_TMPDIR")
base_dir = Path(test_tmpdir) if test_tmpdir else Path(tempfile.gettempdir())
temp_home = base_dir / "ray_test_home"
temp_home.mkdir(parents=True, exist_ok=True)
os.environ["HOME"] = str(temp_home)
default_token_path = Path.home() / _DEFAULT_AUTH_TOKEN_RELATIVE_PATH
default_token_exists = default_token_path.exists()
default_token_contents = (
default_token_path.read_text() if default_token_exists else None
)
return cls(
original_env=original_env,
original_home=original_home,
home_was_set=home_was_set,
temp_home=temp_home,
default_token_path=default_token_path,
default_token_exists=default_token_exists,
default_token_contents=default_token_contents,
)
def clear_default_token(self) -> None:
"""Remove the default token file for the current HOME."""
self.default_token_path.unlink(missing_ok=True)
def restore(self) -> None:
"""Restore the captured environment, HOME, and default token file state."""
# delete any custom token files that may have been created during the test
custom_token_path = os.environ.get("RAY_AUTH_TOKEN_PATH")
if custom_token_path is not None:
custom_token_path = Path(custom_token_path)
if custom_token_path.exists():
custom_token_path.unlink(missing_ok=True)
for var, value in self.original_env.items():
if value is None:
os.environ.pop(var, None)
else:
os.environ[var] = value
if self.home_was_set:
if self.original_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self.original_home
if self.default_token_exists:
self.default_token_path.parent.mkdir(parents=True, exist_ok=True)
self.default_token_path.write_text(self.default_token_contents or "")
else:
self.default_token_path.unlink(missing_ok=True)
if not self.home_was_set:
current_home = os.environ.get("HOME")
if self.temp_home is not None and current_home == str(self.temp_home):
os.environ.pop("HOME", None)
if self.temp_home is not None and self.temp_home.exists():
shutil.rmtree(self.temp_home, ignore_errors=True)
@contextmanager
def authentication_env_guard():
"""Context manager that restores authentication environment state on exit."""
snapshot = AuthenticationEnvSnapshot.capture()
try:
yield snapshot
finally:
snapshot.restore()
+32
View File
@@ -0,0 +1,32 @@
import os
import threading
from functools import wraps
import ray
auto_init_lock = threading.Lock()
enable_auto_connect = os.environ.get("RAY_ENABLE_AUTO_CONNECT", "") != "0"
def auto_init_ray():
if enable_auto_connect and not ray.is_initialized():
with auto_init_lock:
if not ray.is_initialized():
ray.init()
def wrap_auto_init(fn):
@wraps(fn)
def auto_init_wrapper(*args, **kwargs):
auto_init_ray()
return fn(*args, **kwargs)
return auto_init_wrapper
def wrap_auto_init_for_all_apis(api_names):
"""Wrap public APIs with automatic ray.init."""
for api_name in api_names:
api = getattr(ray, api_name, None)
assert api is not None, api_name
setattr(ray, api_name, wrap_auto_init(api))
+221
View File
@@ -0,0 +1,221 @@
import os
import threading
from contextlib import contextmanager
from functools import wraps
from typing import Any, Callable, Optional, Tuple, TypeVar, cast, overload
from ray._private.auto_init_hook import auto_init_ray
F = TypeVar("F", bound=Callable[..., Any])
# Attr set on func defs to mark they have been converted to client mode.
RAY_CLIENT_MODE_ATTR = "__ray_client_mode_key__"
# Global setting of whether client mode is enabled. This default to OFF,
# but is enabled upon ray.client(...).connect() or in tests.
is_client_mode_enabled = os.environ.get("RAY_CLIENT_MODE", "0") == "1"
# When RAY_CLIENT_MODE == 1, we treat it as default enabled client mode
# This is useful for testing
is_client_mode_enabled_by_default = is_client_mode_enabled
os.environ.update({"RAY_CLIENT_MODE": "0"})
is_init_called = False
# Local setting of whether to ignore client hook conversion. This defaults
# to TRUE and is disabled when the underlying 'real' Ray function is needed.
_client_hook_status_on_thread = threading.local()
_client_hook_status_on_thread.status = True
def _get_client_hook_status_on_thread():
"""Get's the value of `_client_hook_status_on_thread`.
Since `_client_hook_status_on_thread` is a thread-local variable, we may
need to add and set the 'status' attribute.
"""
global _client_hook_status_on_thread
if not hasattr(_client_hook_status_on_thread, "status"):
_client_hook_status_on_thread.status = True
return _client_hook_status_on_thread.status
def _set_client_hook_status(val: bool):
global _client_hook_status_on_thread
_client_hook_status_on_thread.status = val
def _disable_client_hook():
global _client_hook_status_on_thread
out = _get_client_hook_status_on_thread()
_client_hook_status_on_thread.status = False
return out
def _explicitly_enable_client_mode():
"""Force client mode to be enabled.
NOTE: This should not be used in tests, use `enable_client_mode`.
"""
global is_client_mode_enabled
is_client_mode_enabled = True
def _explicitly_disable_client_mode():
global is_client_mode_enabled
is_client_mode_enabled = False
@contextmanager
def disable_client_hook():
val = _disable_client_hook()
try:
yield None
finally:
_set_client_hook_status(val)
@contextmanager
def enable_client_mode():
_explicitly_enable_client_mode()
try:
yield None
finally:
_explicitly_disable_client_mode()
@overload
def client_mode_hook(func: F) -> F:
...
@overload
def client_mode_hook(*, local_only_kwargs: Tuple[str, ...] = ()) -> Callable[[F], F]:
...
def client_mode_hook(
func: Optional[F] = None, *, local_only_kwargs: Tuple[str, ...] = ()
):
"""Decorator for whether to use the 'regular' ray version of a function,
or the Ray Client version of that function.
Args:
func: This function. This is set when this function is used
as a bare decorator.
local_only_kwargs: Names of keyword arguments that apply only to the
local (non-client) implementation. They are stripped before the
call is redirected to the Ray Client, so the client API does not
need to accept them. On the local path they pass through unchanged.
as a decorator.
Returns:
The wrapped function that dispatches to the regular or client version.
"""
def decorator(func: F) -> F:
from ray.util.client import ray
@wraps(func)
def wrapper(*args, **kwargs):
# NOTE(hchen): DO NOT use "import" inside this function.
# Because when it's called within a `__del__` method, this error
# will be raised (see #35114):
# ImportError: sys.meta_path is None, Python is likely shutting down.
if client_mode_should_convert():
# Legacy code
# we only convert init function if RAY_CLIENT_MODE=1
if func.__name__ != "init" or is_client_mode_enabled_by_default:
if local_only_kwargs:
kwargs = {
k: v
for k, v in kwargs.items()
if k not in local_only_kwargs
}
return getattr(ray, func.__name__)(*args, **kwargs)
return func(*args, **kwargs)
return cast(F, wrapper)
# Support both `@client_mode_hook` and
# `@client_mode_hook(local_only_kwargs=...)` usage.
if func is not None:
return decorator(func)
return decorator
def client_mode_should_convert():
"""Determines if functions should be converted to client mode."""
# `is_client_mode_enabled_by_default` is used for testing with
# `RAY_CLIENT_MODE=1`. This flag means all tests run with client mode.
return (
is_client_mode_enabled or is_client_mode_enabled_by_default
) and _get_client_hook_status_on_thread()
def client_mode_wrap(func):
"""Wraps a function called during client mode for execution as a remote
task.
Can be used to implement public features of ray client which do not
belong in the main ray API (`ray.*`), yet require server-side execution.
An example is the creation of placement groups:
`ray.util.placement_group.placement_group()`. When called on the client
side, this function is wrapped in a task to facilitate interaction with
the GCS.
"""
@wraps(func)
def wrapper(*args, **kwargs):
from ray.util.client import ray
auto_init_ray()
# Directly pass this through since `client_mode_wrap` is for
# Placement Group APIs
if client_mode_should_convert():
f = ray.remote(num_cpus=0)(func)
ref = f.remote(*args, **kwargs)
return ray.get(ref)
return func(*args, **kwargs)
return wrapper
def client_mode_convert_function(func_cls, in_args, in_kwargs, **kwargs):
"""Runs a preregistered ray RemoteFunction through the ray client.
The common case for this is to transparently convert that RemoteFunction
to a ClientRemoteFunction. This happens in circumstances where the
RemoteFunction is declared early, in a library and only then is Ray used in
client mode -- necessitating a conversion.
"""
from ray.util.client import ray
key = getattr(func_cls, RAY_CLIENT_MODE_ATTR, None)
# Second part of "or" is needed in case func_cls is reused between Ray
# client sessions in one Python interpreter session.
if (key is None) or (not ray._converted_key_exists(key)):
key = ray._convert_function(func_cls)
setattr(func_cls, RAY_CLIENT_MODE_ATTR, key)
client_func = ray._get_converted(key)
return client_func._remote(in_args, in_kwargs, **kwargs)
def client_mode_convert_actor(actor_cls, in_args, in_kwargs, **kwargs):
"""Runs a preregistered actor class on the ray client
The common case for this decorator is for instantiating an ActorClass
transparently as a ClientActorClass. This happens in circumstances where
the ActorClass is declared early, in a library and only then is Ray used in
client mode -- necessitating a conversion.
"""
from ray.util.client import ray
key = getattr(actor_cls, RAY_CLIENT_MODE_ATTR, None)
# Second part of "or" is needed in case actor_cls is reused between Ray
# client sessions in one Python interpreter session.
if (key is None) or (not ray._converted_key_exists(key)):
key = ray._convert_actor(actor_cls)
setattr(actor_cls, RAY_CLIENT_MODE_ATTR, key)
client_actor = ray._get_converted(key)
return client_actor._remote(in_args, in_kwargs, **kwargs)
+10
View File
@@ -0,0 +1,10 @@
from typing import Any, List
def split(items: List[Any], chunk_size: int):
"""Splits provided list into chunks of given size"""
assert chunk_size > 0, "Chunk size has to be > 0"
for i in range(0, len(items), chunk_size):
yield items[i : i + chunk_size]
+40
View File
@@ -0,0 +1,40 @@
import io
import platform
def patch_psutil():
"""WSL's /proc/meminfo has an inconsistency where it
nondeterministically omits a space after colons (after "SwapFree:"
in my case).
psutil then splits on spaces and then parses the wrong field,
crashing on the 'int(fields[1])' expression in
psutil._pslinux.virtual_memory().
Workaround: We ensure there is a space following each colon.
"""
assert (
platform.system() == "Linux"
and "Microsoft".lower() in platform.release().lower()
)
try:
import psutil._pslinux
except ImportError:
psutil = None
psutil_open_binary = None
if psutil:
try:
psutil_open_binary = psutil._pslinux.open_binary
except AttributeError:
pass
# Only patch it if it doesn't seem to have been patched already
if psutil_open_binary and psutil_open_binary.__name__ == "open_binary":
def psutil_open_binary_patched(fname, *args, **kwargs):
f = psutil_open_binary(fname, *args, **kwargs)
if fname == "/proc/meminfo":
with f:
# Make sure there's a space after colons
return io.BytesIO(f.read().replace(b":", b": "))
return f
psutil._pslinux.open_binary = psutil_open_binary_patched
+15
View File
@@ -0,0 +1,15 @@
import pytest
import ray._private.ray_constants as ray_constants
@pytest.fixture
def set_override_dashboard_url(monkeypatch, request):
override_url = getattr(request, "param", "https://external_dashboard_url")
with monkeypatch.context() as m:
if override_url:
m.setenv(
ray_constants.RAY_OVERRIDE_DASHBOARD_URL,
override_url,
)
yield
+154
View File
@@ -0,0 +1,154 @@
from typing import Literal
from ray.core.generated.common_pb2 import (
ErrorType,
Language,
TaskStatus,
TaskType,
WorkerExitType,
WorkerType,
)
from ray.core.generated.gcs_pb2 import (
ActorTableData,
GcsNodeInfo,
PlacementGroupTableData,
)
ACTOR_STATUS = [
"DEPENDENCIES_UNREADY",
"PENDING_CREATION",
"ALIVE",
"RESTARTING",
"DEAD",
]
TypeActorStatus = Literal[tuple(ACTOR_STATUS)]
PLACEMENT_GROUP_STATUS = [
"PENDING",
"PREPARED",
"CREATED",
"REMOVED",
"RESCHEDULING",
]
TypePlacementGroupStatus = Literal[tuple(PLACEMENT_GROUP_STATUS)]
TASK_STATUS = [
"NIL",
"PENDING_ARGS_AVAIL",
"PENDING_NODE_ASSIGNMENT",
"PENDING_OBJ_STORE_MEM_AVAIL",
"PENDING_ARGS_FETCH",
"SUBMITTED_TO_WORKER",
"PENDING_ACTOR_TASK_ARGS_FETCH",
"PENDING_ACTOR_TASK_ORDERING_OR_CONCURRENCY",
"RUNNING",
"RUNNING_IN_RAY_GET",
"RUNNING_IN_RAY_WAIT",
"FINISHED",
"FAILED",
"GETTING_AND_PINNING_ARGS",
]
TypeTaskStatus = Literal[tuple(TASK_STATUS)]
NODE_STATUS = ["ALIVE", "DEAD"]
TypeNodeStatus = Literal[tuple(NODE_STATUS)]
WORKER_TYPE = [
"WORKER",
"DRIVER",
"SPILL_WORKER",
"RESTORE_WORKER",
]
TypeWorkerType = Literal[tuple(WORKER_TYPE)]
WORKER_EXIT_TYPE = [
"SYSTEM_ERROR",
"INTENDED_SYSTEM_EXIT",
"USER_ERROR",
"INTENDED_USER_EXIT",
"NODE_OUT_OF_MEMORY",
]
TypeWorkerExitType = Literal[tuple(WORKER_EXIT_TYPE)]
TASK_TYPE = [
"NORMAL_TASK",
"ACTOR_CREATION_TASK",
"ACTOR_TASK",
"DRIVER_TASK",
]
TypeTaskType = Literal[tuple(TASK_TYPE)]
# TODO(kevin85421): `class ReferenceType(Enum)` is defined in
# `dashboard/memory_utils.py` to avoid complex dependencies. I redefined
# it here. Eventually, we should remove the one in `dashboard/memory_utils.py`
# and define it under `ray/_private`.
REFERENCE_TYPE = [
"ACTOR_HANDLE",
"PINNED_IN_MEMORY",
"LOCAL_REFERENCE",
"USED_BY_PENDING_TASK",
"CAPTURED_IN_OBJECT",
"UNKNOWN_STATUS",
]
TypeReferenceType = Literal[tuple(REFERENCE_TYPE)]
# The ErrorType enum is used in the export API so it is public
# and any modifications must be backward compatible.
ERROR_TYPE = [
"WORKER_DIED",
"ACTOR_DIED",
"TASK_EXECUTION_EXCEPTION",
"OBJECT_IN_PLASMA",
"TASK_CANCELLED",
"ACTOR_CREATION_FAILED",
"RUNTIME_ENV_SETUP_FAILED",
"OBJECT_LOST",
"OWNER_DIED",
"OBJECT_DELETED",
"DEPENDENCY_RESOLUTION_FAILED",
"OBJECT_UNRECONSTRUCTABLE_MAX_ATTEMPTS_EXCEEDED",
"OBJECT_UNRECONSTRUCTABLE_LINEAGE_EVICTED",
"OBJECT_FETCH_TIMED_OUT",
"LOCAL_RAYLET_DIED",
"TASK_PLACEMENT_GROUP_REMOVED",
"ACTOR_PLACEMENT_GROUP_REMOVED",
"TASK_UNSCHEDULABLE_ERROR",
"ACTOR_UNSCHEDULABLE_ERROR",
"OUT_OF_DISK_ERROR",
"OBJECT_FREED",
"OUT_OF_MEMORY",
"NODE_DIED",
"END_OF_STREAMING_GENERATOR",
"ACTOR_UNAVAILABLE",
"GENERATOR_TASK_FAILED_FOR_OBJECT_RECONSTRUCTION",
"OBJECT_UNRECONSTRUCTABLE_PUT",
"OBJECT_UNRECONSTRUCTABLE_RETRIES_DISABLED",
"OBJECT_UNRECONSTRUCTABLE_BORROWED",
"OBJECT_UNRECONSTRUCTABLE_REF_NOT_FOUND",
"OBJECT_UNRECONSTRUCTABLE_TASK_CANCELLED",
"OBJECT_UNRECONSTRUCTABLE_LINEAGE_DISABLED",
"WORKER_STARTUP_FAILED",
]
# The Language enum is used in the export API so it is public
# and any modifications must be backward compatible.
LANGUAGE = ["PYTHON", "JAVA", "CPP"]
def validate_protobuf_enum(grpc_enum, custom_enum):
"""Validate the literal contains the correct enum values from protobuf"""
enum_vals = set(grpc_enum.DESCRIPTOR.values_by_name.keys())
# Sometimes, the grpc enum is mocked, and it
# doesn't include any values in that case.
if len(enum_vals) > 0:
assert enum_vals == set(
custom_enum
), """Literals in `custom_types.py` and `.proto` files are out of sync. \
Consider building //:install_py_proto with Bazel or updating `custom_types.py`."""
# Do the enum validation here.
# It is necessary to avoid regression. Alternatively, we can auto generate this
# directly by protobuf.
validate_protobuf_enum(ActorTableData.ActorState, ACTOR_STATUS)
validate_protobuf_enum(
PlacementGroupTableData.PlacementGroupState, PLACEMENT_GROUP_STATUS
)
validate_protobuf_enum(TaskStatus, TASK_STATUS)
validate_protobuf_enum(GcsNodeInfo.GcsNodeState, NODE_STATUS)
validate_protobuf_enum(WorkerType, WORKER_TYPE)
validate_protobuf_enum(WorkerExitType, WORKER_EXIT_TYPE)
validate_protobuf_enum(TaskType, TASK_TYPE)
validate_protobuf_enum(ErrorType, ERROR_TYPE)
validate_protobuf_enum(Language, LANGUAGE)
+253
View File
@@ -0,0 +1,253 @@
import copy
from collections import deque
from collections.abc import Mapping, Sequence
from typing import Dict, List, Optional, TypeVar, Union
from ray.util.annotations import Deprecated
T = TypeVar("T")
@Deprecated
def merge_dicts(d1: dict, d2: dict) -> dict:
"""
Args:
d1: Dict 1.
d2: Dict 2.
Returns:
A new dict that is d1 and d2 deep merged.
"""
merged = copy.deepcopy(d1)
deep_update(merged, d2, True, [])
return merged
@Deprecated
def deep_update(
original: dict,
new_dict: dict,
new_keys_allowed: bool = False,
allow_new_subkey_list: Optional[List[str]] = None,
override_all_if_type_changes: Optional[List[str]] = None,
override_all_key_list: Optional[List[str]] = None,
) -> dict:
"""Updates original dict with values from new_dict recursively.
If new key is introduced in new_dict, then if new_keys_allowed is not
True, an error will be thrown. Further, for sub-dicts, if the key is
in the allow_new_subkey_list, then new subkeys can be introduced.
Args:
original: Dictionary with default values.
new_dict: Dictionary with values to be updated
new_keys_allowed: Whether new keys are allowed.
allow_new_subkey_list: List of keys that
correspond to dict values where new subkeys can be introduced.
This is only at the top level.
override_all_if_type_changes: List of top level
keys with value=dict, for which we always simply override the
entire value (dict), iff the "type" key in that value dict changes.
override_all_key_list: List of top level keys
for which we override the entire value if the key is in the new_dict.
Returns:
The updated original dict.
"""
allow_new_subkey_list = allow_new_subkey_list or []
override_all_if_type_changes = override_all_if_type_changes or []
override_all_key_list = override_all_key_list or []
for k, value in new_dict.items():
if k not in original and not new_keys_allowed:
raise Exception("Unknown config parameter `{}` ".format(k))
# Both orginal value and new one are dicts.
if (
isinstance(original.get(k), dict)
and isinstance(value, dict)
and k not in override_all_key_list
):
# Check old type vs old one. If different, override entire value.
if (
k in override_all_if_type_changes
and "type" in value
and "type" in original[k]
and value["type"] != original[k]["type"]
):
original[k] = value
# Allowed key -> ok to add new subkeys.
elif k in allow_new_subkey_list:
deep_update(
original[k],
value,
True,
override_all_key_list=override_all_key_list,
)
# Non-allowed key.
else:
deep_update(
original[k],
value,
new_keys_allowed,
override_all_key_list=override_all_key_list,
)
# Original value not a dict OR new value not a dict:
# Override entire value.
else:
original[k] = value
return original
@Deprecated
def flatten_dict(
dt: Dict,
delimiter: str = "/",
prevent_delimiter: bool = False,
flatten_list: bool = False,
):
"""Flatten dict.
Output and input are of the same dict type.
Input dict remains the same after the operation.
"""
def _raise_delimiter_exception():
raise ValueError(
f"Found delimiter `{delimiter}` in key when trying to flatten "
f"array. Please avoid using the delimiter in your specification."
)
dt = copy.copy(dt)
if prevent_delimiter and any(delimiter in key for key in dt):
# Raise if delimiter is any of the keys
_raise_delimiter_exception()
while_check = (dict, list) if flatten_list else dict
while any(isinstance(v, while_check) for v in dt.values()):
remove = []
add = {}
for key, value in dt.items():
if isinstance(value, dict):
for subkey, v in value.items():
if prevent_delimiter and delimiter in subkey:
# Raise if delimiter is in any of the subkeys
_raise_delimiter_exception()
add[delimiter.join([key, str(subkey)])] = v
remove.append(key)
elif flatten_list and isinstance(value, list):
for i, v in enumerate(value):
if prevent_delimiter and delimiter in subkey:
# Raise if delimiter is in any of the subkeys
_raise_delimiter_exception()
add[delimiter.join([key, str(i)])] = v
remove.append(key)
dt.update(add)
for k in remove:
del dt[k]
return dt
@Deprecated
def unflatten_dict(dt: Dict[str, T], delimiter: str = "/") -> Dict[str, T]:
"""Unflatten dict. Does not support unflattening lists."""
dict_type = type(dt)
out = dict_type()
for key, val in dt.items():
path = key.split(delimiter)
item = out
for k in path[:-1]:
item = item.setdefault(k, dict_type())
if not isinstance(item, dict_type):
raise TypeError(
f"Cannot unflatten dict due the key '{key}' "
f"having a parent key '{k}', which value is not "
f"of type {dict_type} (got {type(item)}). "
"Change the key names to resolve the conflict."
)
item[path[-1]] = val
return out
@Deprecated
def unflatten_list_dict(dt: Dict[str, T], delimiter: str = "/") -> Dict[str, T]:
"""Unflatten nested dict and list.
This function now has some limitations:
(1) The keys of dt must be str.
(2) If unflattened dt (the result) contains list, the index order must be
ascending when accessing dt. Otherwise, this function will throw
AssertionError.
(3) The unflattened dt (the result) shouldn't contain dict with number
keys.
Be careful to use this function. If you want to improve this function,
please also improve the unit test. See #14487 for more details.
Args:
dt: Flattened dictionary that is originally nested by multiple
list and dict.
delimiter: Delimiter of keys.
Returns:
The unflattened nested dict/list.
Example:
>>> dt = {"aaa/0/bb": 12, "aaa/1/cc": 56, "aaa/1/dd": 92}
>>> unflatten_list_dict(dt)
{'aaa': [{'bb': 12}, {'cc': 56, 'dd': 92}]}
"""
out_type = list if list(dt)[0].split(delimiter, 1)[0].isdigit() else type(dt)
out = out_type()
for key, val in dt.items():
path = key.split(delimiter)
item = out
for i, k in enumerate(path[:-1]):
next_type = list if path[i + 1].isdigit() else dict
if isinstance(item, dict):
item = item.setdefault(k, next_type())
elif isinstance(item, list):
if int(k) >= len(item):
item.append(next_type())
assert int(k) == len(item) - 1
item = item[int(k)]
if isinstance(item, dict):
item[path[-1]] = val
elif isinstance(item, list):
item.append(val)
assert int(path[-1]) == len(item) - 1
return out
@Deprecated
def unflattened_lookup(
flat_key: str, lookup: Union[Mapping, Sequence], delimiter: str = "/", **kwargs
) -> Union[Mapping, Sequence]:
"""
Unflatten `flat_key` and iteratively look up in `lookup`. E.g.
`flat_key="a/0/b"` will try to return `lookup["a"][0]["b"]`.
"""
if flat_key in lookup:
return lookup[flat_key]
keys = deque(flat_key.split(delimiter))
base = lookup
while keys:
key = keys.popleft()
try:
if isinstance(base, Mapping):
base = base[key]
elif isinstance(base, Sequence):
base = base[int(key)]
else:
raise KeyError()
except KeyError as e:
if "default" in kwargs:
return kwargs["default"]
raise e
return base
+202
View File
@@ -0,0 +1,202 @@
import json
import logging
import os
import pathlib
import random
import socket
import string
import threading
from datetime import datetime
from typing import Dict, Optional
from google.protobuf.json_format import Parse
from ray._private.protobuf_compat import message_to_dict
from ray.core.generated.event_pb2 import Event
global_logger = logging.getLogger(__name__)
def get_event_id():
return "".join([random.choice(string.hexdigits) for _ in range(36)])
class EventLoggerAdapter:
def __init__(self, source: Event.SourceType, logger: logging.Logger):
"""Adapter for the Python logger that's used to emit events.
When events are emitted, they are aggregated and available via
state API and dashboard.
This class is thread-safe.
"""
self.logger = logger
# Aligned with `event.proto`'s `message Event``
self.source = source
self.source_hostname = socket.gethostname()
self.source_pid = os.getpid()
# The below fields must be protected by this lock.
self.lock = threading.Lock()
# {str -> str} typed dict
self.global_context = {}
def set_global_context(self, global_context: Dict[str, str] = None):
"""Set the global metadata.
This method overwrites the global metadata if it is called more than once.
"""
with self.lock:
self.global_context = {} if not global_context else global_context
def trace(self, message: str, **kwargs):
self._emit(Event.Severity.TRACE, message, **kwargs)
def debug(self, message: str, **kwargs):
self._emit(Event.Severity.DEBUG, message, **kwargs)
def info(self, message: str, **kwargs):
self._emit(Event.Severity.INFO, message, **kwargs)
def warning(self, message: str, **kwargs):
self._emit(Event.Severity.WARNING, message, **kwargs)
def error(self, message: str, **kwargs):
self._emit(Event.Severity.ERROR, message, **kwargs)
def fatal(self, message: str, **kwargs):
self._emit(Event.Severity.FATAL, message, **kwargs)
def _emit(self, severity: Event.Severity, message: str, **kwargs):
# NOTE: Python logger is thread-safe,
# so we don't need to protect it using locks.
event = Event()
event.event_id = get_event_id()
event.timestamp = int(datetime.now().timestamp())
event.message = message
event.severity = severity
# TODO(sang): Support event type & schema.
event.label = ""
event.source_type = self.source
event.source_hostname = self.source_hostname
event.source_pid = self.source_pid
custom_fields = event.custom_fields
with self.lock:
for k, v in self.global_context.items():
if v is not None and k is not None:
custom_fields[k] = v
for k, v in kwargs.items():
if v is not None and k is not None:
custom_fields[k] = v
self.logger.info(
json.dumps(
message_to_dict(
event,
always_print_fields_with_no_presence=True,
preserving_proto_field_name=True,
use_integers_for_enums=False,
)
)
)
# Force flush all handlers so that we won't lose events.
for handler in self.logger.handlers[:]:
try:
handler.flush()
except Exception:
global_logger.exception("Failed to flush event logger handler.")
def _build_event_file_logger(source: Event.SourceType, sink_dir: str):
logger = logging.getLogger("_ray_event_logger")
logger.setLevel(logging.INFO)
dir_path = pathlib.Path(sink_dir) / "events"
filepath = dir_path / f"event_{source}.log"
dir_path.mkdir(exist_ok=True)
filepath.touch(exist_ok=True)
# Configure the logger.
handler = logging.FileHandler(filepath)
formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.propagate = False
return logger
# This lock must be used when accessing or updating global event logger dict.
_event_logger_lock = threading.Lock()
_event_logger = {}
def get_event_logger(source: Event.SourceType, sink_dir: str):
"""Get the event logger of the current process.
There's only 1 event logger per (process, source).
TODO(sang): Support more impl than file-based logging.
Currently, the interface also ties to the
file-based logging impl.
Args:
source: The source of the event.
sink_dir: The directory to sink event logs.
Returns:
The event logger adapter for the given source.
"""
with _event_logger_lock:
global _event_logger
source_name = Event.SourceType.Name(source)
if source_name not in _event_logger:
logger = _build_event_file_logger(source_name, sink_dir)
_event_logger[source_name] = EventLoggerAdapter(source, logger)
return _event_logger[source_name]
def parse_event(event_str: str) -> Optional[Event]:
"""Parse an event from a string.
Args:
event_str: The string to parse. Expect to be a JSON serialized
Event protobuf.
Returns:
The parsed event if parsable, else None
"""
try:
return Parse(event_str, Event())
except Exception:
global_logger.exception(f"Failed to parse event: {event_str}")
return None
def filter_event_by_level(event: Event, filter_event_level: str) -> bool:
"""Filter an event based on event level.
Args:
event: The event to filter.
filter_event_level: The event level string to filter by. Any events
that are lower than this level will be filtered.
Returns:
True if the event should be filtered, else False.
"""
event_levels = {
Event.Severity.TRACE: 0,
Event.Severity.DEBUG: 1,
Event.Severity.INFO: 2,
Event.Severity.WARNING: 3,
Event.Severity.ERROR: 4,
Event.Severity.FATAL: 5,
}
filter_event_level = filter_event_level.upper()
filter_event_level = Event.Severity.Value(filter_event_level)
if event_levels[event.severity] < event_levels[filter_event_level]:
return True
return False
@@ -0,0 +1,261 @@
import json
import logging
import pathlib
import random
import string
import threading
from datetime import datetime
from enum import Enum
from typing import Union
from ray._private import ray_constants
from ray._private.protobuf_compat import message_to_dict
from ray.core.generated.export_dataset_metadata_pb2 import (
ExportDatasetMetadata,
)
from ray.core.generated.export_dataset_operator_event_pb2 import (
ExportDatasetOperatorEventData,
)
from ray.core.generated.export_dataset_operator_schema_pb2 import (
ExportDatasetOperatorSchema,
)
from ray.core.generated.export_event_pb2 import ExportEvent
from ray.core.generated.export_submission_job_event_pb2 import (
ExportSubmissionJobEventData,
)
from ray.core.generated.export_train_state_pb2 import (
ExportTrainRunAttemptEventData,
ExportTrainRunEventData,
)
global_logger = logging.getLogger(__name__)
# This contains the union of export event data types which emit events
# using the python ExportEventLoggerAdapter
ExportEventDataType = Union[
ExportSubmissionJobEventData,
ExportTrainRunEventData,
ExportTrainRunAttemptEventData,
ExportDatasetMetadata,
ExportDatasetOperatorEventData,
ExportDatasetOperatorSchema,
]
class EventLogType(Enum):
"""Enum class representing different types of export event logs.
Each enum value contains a log type name and a set of supported event data types.
Attributes:
TRAIN_STATE: Export events related to training state, supporting train run and attempt events.
SUBMISSION_JOB: Export events related to job submissions.
DATASET_METADATA: Export events related to dataset metadata.
DATASET_OPERATOR_EVENT: Export events related to Ray Data operator.
DATASET_OPERATOR_SCHEMA: Export schema related to Ray Data operator.
"""
TRAIN_STATE = (
"EXPORT_TRAIN_STATE",
{ExportTrainRunEventData, ExportTrainRunAttemptEventData},
)
SUBMISSION_JOB = ("EXPORT_SUBMISSION_JOB", {ExportSubmissionJobEventData})
DATASET_METADATA = ("EXPORT_DATASET_METADATA", {ExportDatasetMetadata})
DATASET_OPERATOR_EVENT = (
"EXPORT_DATASET_OPERATOR_EVENT",
{ExportDatasetOperatorEventData},
)
DATASET_OPERATOR_SCHEMA = (
"EXPORT_DATASET_OPERATOR_SCHEMA",
{ExportDatasetOperatorSchema},
)
def __init__(self, log_type_name: str, event_types: set[ExportEventDataType]):
"""Initialize an EventLogType enum value.
Args:
log_type_name: String identifier for the log type. This name is used to construct the log file name.
See `_build_export_event_file_logger` for more details.
event_types: Set of event data types that this log type supports.
"""
self.log_type_name = log_type_name
self.event_types = event_types
def supports_event_type(self, event_type: ExportEventDataType) -> bool:
"""Check if this log type supports the given event data type.
Args:
event_type: The event data type to check for support.
Returns:
bool: True if the event type is supported, False otherwise.
"""
return type(event_type) in self.event_types
def generate_event_id():
return "".join([random.choice(string.hexdigits) for _ in range(18)])
class ExportEventLoggerAdapter:
def __init__(self, log_type: EventLogType, logger: logging.Logger):
"""Adapter for the Python logger that's used to emit export events."""
self.logger = logger
self.log_type = log_type
def send_event(self, event_data: ExportEventDataType):
# NOTE: Python logger is thread-safe,
# so we don't need to protect it using locks.
try:
event = self._create_export_event(event_data)
except TypeError:
global_logger.exception(
"Failed to create ExportEvent from event_data so no "
"event will be written to file."
)
return
event_as_str = self._export_event_to_string(event)
self.logger.info(event_as_str)
# Force flush all handlers so that we won't lose events.
for handler in self.logger.handlers[:]:
try:
handler.flush()
except Exception:
global_logger.exception("Failed to flush export event logger handler.")
def _create_export_event(self, event_data: ExportEventDataType) -> ExportEvent:
event = ExportEvent()
event.event_id = generate_event_id()
event.timestamp = int(datetime.now().timestamp())
if isinstance(event_data, ExportSubmissionJobEventData):
event.submission_job_event_data.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_SUBMISSION_JOB
elif isinstance(event_data, ExportTrainRunEventData):
event.train_run_event_data.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_TRAIN_RUN
elif isinstance(event_data, ExportTrainRunAttemptEventData):
event.train_run_attempt_event_data.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_TRAIN_RUN_ATTEMPT
elif isinstance(event_data, ExportDatasetMetadata):
event.dataset_metadata.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_DATASET_METADATA
elif isinstance(event_data, ExportDatasetOperatorEventData):
event.dataset_operator_event_data.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_DATASET_OPERATOR_EVENT
elif isinstance(event_data, ExportDatasetOperatorSchema):
event.dataset_operator_schema.CopyFrom(event_data)
event.source_type = ExportEvent.SourceType.EXPORT_DATASET_OPERATOR_SCHEMA
else:
raise TypeError(f"Invalid event_data type: {type(event_data)}")
if not self.log_type.supports_event_type(event_data):
global_logger.error(
f"event_data has source type {event.source_type}, however "
f"the event was sent to a logger with log type {self.log_type.log_type_name}. "
f"The event will still be written to the file of {self.log_type.log_type_name} "
"but this indicates a bug in the code."
)
pass
return event
def _export_event_to_string(self, event: ExportEvent) -> str:
event_data_json = {}
proto_to_dict_options = {
"always_print_fields_with_no_presence": True,
"preserving_proto_field_name": True,
"use_integers_for_enums": False,
}
event_data_field_set = event.WhichOneof("event_data")
if event_data_field_set:
event_data_json = message_to_dict(
getattr(event, event_data_field_set),
**proto_to_dict_options,
)
else:
global_logger.error(
f"event_data missing from export event with id {event.event_id} "
f"and type {event.source_type}. An empty event will be written, "
"but this indicates a bug in the code. "
)
pass
event_json = {
"event_id": event.event_id,
"timestamp": event.timestamp,
"source_type": ExportEvent.SourceType.Name(event.source_type),
"event_data": event_data_json,
}
return json.dumps(event_json)
def _build_export_event_file_logger(
log_type_name: str, sink_dir: str
) -> logging.Logger:
logger = logging.getLogger("_ray_export_event_logger_" + log_type_name)
logger.setLevel(logging.INFO)
dir_path = pathlib.Path(sink_dir) / "export_events"
filepath = dir_path / f"event_{log_type_name}.log"
dir_path.mkdir(exist_ok=True)
filepath.touch(exist_ok=True)
# Configure the logger.
# Default is 100 MB max file size
handler = logging.handlers.RotatingFileHandler(
filepath,
maxBytes=(ray_constants.RAY_EXPORT_EVENT_MAX_FILE_SIZE_BYTES),
backupCount=ray_constants.RAY_EXPORT_EVENT_MAX_BACKUP_COUNT,
)
logger.addHandler(handler)
logger.propagate = False
return logger
# This lock must be used when accessing or updating global event logger dict.
_export_event_logger_lock = threading.Lock()
_export_event_logger = {}
def get_export_event_logger(log_type: EventLogType, sink_dir: str) -> logging.Logger:
"""Get the export event logger of the current process.
There's only one logger per export event source.
Args:
log_type: The type of the export event.
sink_dir: The directory to sink event logs.
Returns:
The export event logger adapter for the given log type.
"""
with _export_event_logger_lock:
global _export_event_logger
log_type_name = log_type.log_type_name
if log_type_name not in _export_event_logger:
logger = _build_export_event_file_logger(log_type.log_type_name, sink_dir)
_export_event_logger[log_type_name] = ExportEventLoggerAdapter(
log_type, logger
)
return _export_event_logger[log_type_name]
def check_export_api_enabled(
source: ExportEvent.SourceType,
) -> bool:
"""
Check RAY_ENABLE_EXPORT_API_WRITE and RAY_ENABLE_EXPORT_API_WRITE_CONFIG environment
variables to verify if export events should be written for the given source type.
Args:
source: The source of the export event.
Returns:
True if the export API is enabled for the given source, else False.
"""
if ray_constants.RAY_ENABLE_EXPORT_API_WRITE:
return True
source_name = ExportEvent.SourceType.Name(source)
return (
source_name in ray_constants.RAY_ENABLE_EXPORT_API_WRITE_CONFIG
if ray_constants.RAY_ENABLE_EXPORT_API_WRITE_CONFIG
else False
)
+676
View File
@@ -0,0 +1,676 @@
import abc
import logging
import os
import random
import shutil
import time
import urllib
import uuid
from collections import namedtuple
from typing import IO, List, Optional, Tuple, Union
import ray
from ray._private.ray_constants import DEFAULT_OBJECT_PREFIX
from ray._raylet import ObjectRef
ParsedURL = namedtuple("ParsedURL", "base_url, offset, size")
logger = logging.getLogger(__name__)
def create_url_with_offset(*, url: str, offset: int, size: int) -> str:
"""Methods to create a URL with offset.
When ray spills objects, it fuses multiple objects
into one file to optimize the performance. That says, each object
needs to keep tracking of its own special url to store metadata.
This method creates an url_with_offset, which is used internally
by Ray.
Created url_with_offset can be passed to the self._get_base_url method
to parse the filename used to store files.
Example) file://path/to/file?offset=""&size=""
Args:
url: url to the object stored in the external storage.
offset: Offset from the beginning of the file to
the first bytes of this object.
size: Size of the object that is stored in the url.
It is used to calculate the last offset.
Returns:
url_with_offset stored internally to find
objects from external storage.
"""
return f"{url}?offset={offset}&size={size}"
def parse_url_with_offset(url_with_offset: str) -> Tuple[str, int, int]:
"""Parse url_with_offset to retrieve information.
base_url is the url where the object ref
is stored in the external storage.
Args:
url_with_offset: url created by create_url_with_offset.
Returns:
named tuple of base_url, offset, and size.
"""
parsed_result = urllib.parse.urlparse(url_with_offset)
query_dict = urllib.parse.parse_qs(parsed_result.query)
# Split by ? to remove the query from the url.
base_url = parsed_result.geturl().split("?")[0]
if "offset" not in query_dict or "size" not in query_dict:
raise ValueError(f"Failed to parse URL: {url_with_offset}")
offset = int(query_dict["offset"][0])
size = int(query_dict["size"][0])
return ParsedURL(base_url=base_url, offset=offset, size=size)
class ExternalStorage(metaclass=abc.ABCMeta):
"""The base class for external storage.
This class provides some useful functions for zero-copy object
put/get from plasma store. Also it specifies the interface for
object spilling.
When inheriting this class, please make sure to implement validation
logic inside __init__ method. When ray instance starts, it will
instantiating external storage to validate the config.
"""
HEADER_LENGTH = 24
CORE_WORKER_INIT_GRACE_PERIOD_S = 1
def __init__(self):
# NOTE(edoakes): do not access this field directly. Use the `core_worker`
# property instead to handle initialization race conditions.
self._core_worker: Optional["ray._raylet.CoreWorker"] = None
@property
def core_worker(self) -> "ray._raylet.CoreWorker":
"""Get the core_worker initialized in this process.
In rare cases, the core worker may not be fully initialized by the time an I/O
worker begins to execute an operation because there is no explicit flag set to
indicate that the Python layer is ready to execute tasks.
"""
if self._core_worker is None:
worker = ray._private.worker.global_worker
start = time.time()
while not worker.connected:
time.sleep(0.001)
if time.time() - start > self.CORE_WORKER_INIT_GRACE_PERIOD_S:
raise RuntimeError(
"CoreWorker didn't initialize within grace period of "
f"{self.CORE_WORKER_INIT_GRACE_PERIOD_S}s."
)
self._core_worker = worker.core_worker
return self._core_worker
def _get_objects_from_store(self, object_refs):
# Since the object should always exist in the plasma store before
# spilling, it can directly get the object from the local plasma
# store.
# issue: https://github.com/ray-project/ray/pull/13831
return self.core_worker.get_if_local(object_refs)
def _put_object_to_store(
self, metadata, data_size, file_like, object_ref, owner_address
):
self.core_worker.put_file_like_object(
metadata, data_size, file_like, object_ref, owner_address
)
def _write_multiple_objects(
self, f: IO, object_refs: List[ObjectRef], owner_addresses: List[str], url: str
) -> List[str]:
"""Fuse all given objects into a given file handle.
Args:
f: File handle to fusion all given object refs.
object_refs: Object references to fusion to a single file.
owner_addresses: Owner addresses for the provided objects.
url: url where the object ref is stored
in the external storage.
Returns:
List of urls_with_offset of fused objects.
The order of returned keys are equivalent to the one
with given object_refs.
Raises:
ValueError: when given configuration for
the external storage is invalid.
"""
keys = []
offset = 0
ray_object_pairs = self._get_objects_from_store(object_refs)
for ref, (buf, metadata, _), owner_address in zip(
object_refs, ray_object_pairs, owner_addresses
):
address_len = len(owner_address)
metadata_len = len(metadata)
if buf is None and len(metadata) == 0:
error = f"Object {ref.hex()} does not exist."
raise ValueError(error)
buf_len = 0 if buf is None else len(buf)
header = (
address_len.to_bytes(8, byteorder="little")
+ metadata_len.to_bytes(8, byteorder="little")
+ buf_len.to_bytes(8, byteorder="little")
+ owner_address
+ metadata
)
# 24 bytes to store owner address, metadata, and buffer lengths.
payload_len = self.HEADER_LENGTH + address_len + metadata_len + buf_len
written_bytes = f.write(header)
if buf_len:
written_bytes += f.write(memoryview(buf))
assert written_bytes == payload_len
url_with_offset = create_url_with_offset(
url=url, offset=offset, size=written_bytes
)
keys.append(url_with_offset.encode())
offset += written_bytes
# Necessary because pyarrow.io.NativeFile does not flush() on close().
f.flush()
return keys
def _size_check(
self,
address_len: int,
metadata_len: int,
buffer_len: int,
obtained_data_size: int,
):
"""Check whether or not the obtained_data_size is as expected.
Args:
address_len: Length of the address.
metadata_len: Actual metadata length of the object.
buffer_len: Actual buffer length of the object.
obtained_data_size: Data size specified in the url_with_offset.
Raises:
ValueError: If obtained_data_size is different from
address_len + metadata_len + buffer_len + 24 (first 8 bytes to store length).
"""
data_size_in_bytes = (
address_len + metadata_len + buffer_len + self.HEADER_LENGTH
)
if data_size_in_bytes != obtained_data_size:
raise ValueError(
f"Obtained data has a size of {data_size_in_bytes}, "
"although it is supposed to have the "
f"size of {obtained_data_size}."
)
@abc.abstractmethod
def spill_objects(
self, object_refs: List[ObjectRef], owner_addresses: List[str]
) -> List[str]:
"""Spill objects to the external storage. Objects are specified
by their object refs.
Args:
object_refs: The list of the refs of the objects to be spilled.
owner_addresses: Owner addresses for the provided objects.
Returns:
A list of internal URLs with object offset.
"""
@abc.abstractmethod
def restore_spilled_objects(
self, object_refs: List[ObjectRef], url_with_offset_list: List[str]
) -> int:
"""Restore objects from the external storage.
Args:
object_refs: List of object IDs (note that it is not ref).
url_with_offset_list: List of url_with_offset.
Returns:
The total number of bytes restored.
"""
@abc.abstractmethod
def delete_spilled_objects(self, urls: List[str]):
"""Delete objects that are spilled to the external storage.
Args:
urls: URLs that store spilled object files.
NOTE: This function should not fail if some of the urls
do not exist.
"""
@abc.abstractmethod
def destroy_external_storage(self):
"""Destroy external storage when a head node is down.
NOTE: This is currently working when the cluster is
started by ray.init
"""
class NullStorage(ExternalStorage):
"""The class that represents an uninitialized external storage."""
def spill_objects(self, object_refs, owner_addresses) -> List[str]:
raise NotImplementedError("External storage is not initialized")
def restore_spilled_objects(self, object_refs, url_with_offset_list):
raise NotImplementedError("External storage is not initialized")
def delete_spilled_objects(self, urls: List[str]):
raise NotImplementedError("External storage is not initialized")
def destroy_external_storage(self):
raise NotImplementedError("External storage is not initialized")
class FileSystemStorage(ExternalStorage):
"""The class for filesystem-like external storage."""
def __init__(
self,
node_id: str,
directory_path: Union[str, List[str]],
buffer_size: Optional[int] = None,
):
"""Initialize FileSystemStorage.
Args:
node_id: The ID of the node this storage is associated with.
directory_path: A path or list of paths to spill objects to.
buffer_size: File buffer size used for writes.
Raises:
ValueError: Raises directory path to spill objects doesn't exist.
"""
super().__init__()
# -- A list of directory paths to spill objects --
self._directory_paths = []
# -- Current directory to spill objects --
self._current_directory_index = 0
# -- File buffer size to spill objects --
self._buffer_size = -1
# Validation.
assert (
directory_path is not None
), "directory_path should be provided to use object spilling."
if isinstance(directory_path, str):
directory_path = [directory_path]
assert isinstance(
directory_path, list
), "Directory_path must be either a single string or a list of strings"
if buffer_size is not None:
assert isinstance(buffer_size, int), "buffer_size must be an integer."
self._buffer_size = buffer_size
# Create directories.
for path in directory_path:
full_dir_path = os.path.join(path, f"{DEFAULT_OBJECT_PREFIX}_{node_id}")
os.makedirs(full_dir_path, exist_ok=True)
if not os.path.exists(full_dir_path):
raise ValueError(
"The given directory path to store objects, "
f"{full_dir_path}, could not be created."
)
self._directory_paths.append(full_dir_path)
assert len(self._directory_paths) == len(directory_path)
# Choose the current directory.
# It chooses a random index to maximize multiple directories that are
# mounted at different point.
self._current_directory_index = random.randrange(0, len(self._directory_paths))
def spill_objects(self, object_refs, owner_addresses) -> List[str]:
if len(object_refs) == 0:
return []
# Choose the current directory path by round robin order.
self._current_directory_index = (self._current_directory_index + 1) % len(
self._directory_paths
)
directory_path = self._directory_paths[self._current_directory_index]
filename = _get_unique_spill_filename(object_refs)
url = f"{os.path.join(directory_path, filename)}"
with open(url, "wb", buffering=self._buffer_size) as f:
return self._write_multiple_objects(f, object_refs, owner_addresses, url)
def restore_spilled_objects(
self, object_refs: List[ObjectRef], url_with_offset_list: List[str]
):
total = 0
for i in range(len(object_refs)):
object_ref = object_refs[i]
url_with_offset = url_with_offset_list[i].decode()
# Retrieve the information needed.
parsed_result = parse_url_with_offset(url_with_offset)
base_url = parsed_result.base_url
offset = parsed_result.offset
# Read a part of the file and recover the object.
with open(base_url, "rb") as f:
f.seek(offset)
address_len = int.from_bytes(f.read(8), byteorder="little")
metadata_len = int.from_bytes(f.read(8), byteorder="little")
buf_len = int.from_bytes(f.read(8), byteorder="little")
self._size_check(address_len, metadata_len, buf_len, parsed_result.size)
total += buf_len
owner_address = f.read(address_len)
metadata = f.read(metadata_len)
# read remaining data to our buffer
self._put_object_to_store(
metadata, buf_len, f, object_ref, owner_address
)
return total
def delete_spilled_objects(self, urls: List[str]):
for url in urls:
path = parse_url_with_offset(url.decode()).base_url
try:
os.remove(path)
except FileNotFoundError:
# Occurs when the urls are retried during worker crash/failure.
pass
def destroy_external_storage(self):
for directory_path in self._directory_paths:
self._destroy_external_storage(directory_path)
def _destroy_external_storage(self, directory_path):
# There's a race condition where IO workers are still
# deleting each objects while we try deleting the
# whole directory. So we should keep trying it until
# The directory is actually deleted.
while os.path.isdir(directory_path):
try:
shutil.rmtree(directory_path)
except (FileNotFoundError):
# If exception occurs when other IO workers are
# deleting the file at the same time.
pass
except Exception:
logger.exception(
"Error cleaning up spill files. "
"You might still have remaining spilled "
"objects inside `ray_spilled_objects` directory."
)
break
class ExternalStorageSmartOpenImpl(ExternalStorage):
"""The external storage class implemented by smart_open.
(https://github.com/RaRe-Technologies/smart_open)
Smart open supports multiple backend with the same APIs.
To use this implementation, you should pre-create the given uri.
For example, if your uri is a local file path, you should pre-create
the directory.
"""
def __init__(
self,
node_id: str,
uri: Union[str, list],
override_transport_params: dict = None,
buffer_size: int = 1024
* 1024, # For remote spilling, at least 1MB is recommended.
):
"""Initialize ExternalStorageSmartOpenImpl.
Args:
node_id: The ID of the node this storage is associated with.
uri: Storage URI used for smart open.
override_transport_params: Overriding the default value of
transport_params for smart-open library.
buffer_size: File buffer size used for writes.
Raises:
ModuleNotFoundError: If it fails to setup. For example, if smart
open library is not downloaded, this will fail.
"""
super().__init__()
try:
from smart_open import open # noqa
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"Smart open is chosen to be a object spilling "
"external storage, but smart_open and boto3 "
f"is not downloaded. Original error: {e}"
)
# Validation
assert uri is not None, "uri should be provided to use object spilling."
if isinstance(uri, str):
uri = [uri]
assert isinstance(uri, list), "uri must be a single string or list of strings."
assert isinstance(buffer_size, int), "buffer_size must be an integer."
uri_is_s3 = [u.startswith("s3://") for u in uri]
self.is_for_s3 = all(uri_is_s3)
if not self.is_for_s3:
assert not any(uri_is_s3), "all uri's must be s3 or none can be s3."
self._uris = uri
else:
self._uris = [u.strip("/") for u in uri]
assert len(self._uris) == len(uri)
self._current_uri_index = random.randrange(0, len(self._uris))
self.prefix = f"{DEFAULT_OBJECT_PREFIX}_{node_id}"
self.override_transport_params = override_transport_params or {}
if self.is_for_s3:
import boto3 # noqa
# Setup boto3. It is essential because if we don't create boto
# session, smart_open will create a new session for every
# open call.
self.s3 = boto3.resource(service_name="s3")
# smart_open always seek to 0 if we don't set this argument.
# This will lead us to call a Object.get when it is not necessary,
# so defer seek and call seek before reading objects instead.
self.transport_params = {
"defer_seek": True,
"resource": self.s3,
"buffer_size": buffer_size,
}
else:
self.transport_params = {}
self.transport_params.update(self.override_transport_params)
def spill_objects(self, object_refs, owner_addresses) -> List[str]:
if len(object_refs) == 0:
return []
from smart_open import open
# Choose the current uri by round robin order.
self._current_uri_index = (self._current_uri_index + 1) % len(self._uris)
uri = self._uris[self._current_uri_index]
key = f"{self.prefix}-{_get_unique_spill_filename(object_refs)}"
url = f"{uri}/{key}"
with open(
url,
mode="wb",
transport_params=self.transport_params,
) as file_like:
return self._write_multiple_objects(
file_like, object_refs, owner_addresses, url
)
def restore_spilled_objects(
self, object_refs: List[ObjectRef], url_with_offset_list: List[str]
):
from smart_open import open
total = 0
for i in range(len(object_refs)):
object_ref = object_refs[i]
url_with_offset = url_with_offset_list[i].decode()
# Retrieve the information needed.
parsed_result = parse_url_with_offset(url_with_offset)
base_url = parsed_result.base_url
offset = parsed_result.offset
with open(base_url, "rb", transport_params=self.transport_params) as f:
# smart open seek reads the file from offset-end_of_the_file
# when the seek is called.
f.seek(offset)
address_len = int.from_bytes(f.read(8), byteorder="little")
metadata_len = int.from_bytes(f.read(8), byteorder="little")
buf_len = int.from_bytes(f.read(8), byteorder="little")
self._size_check(address_len, metadata_len, buf_len, parsed_result.size)
owner_address = f.read(address_len)
total += buf_len
metadata = f.read(metadata_len)
# read remaining data to our buffer
self._put_object_to_store(
metadata, buf_len, f, object_ref, owner_address
)
return total
def delete_spilled_objects(self, urls: List[str]):
pass
def destroy_external_storage(self):
pass
_external_storage = NullStorage()
class UnstableFileStorage(FileSystemStorage):
"""This class is for testing with writing failure."""
def __init__(self, node_id: str, **kwargs):
super().__init__(node_id, **kwargs)
self._failure_rate = 0.1
self._partial_failure_ratio = 0.2
def spill_objects(self, object_refs, owner_addresses) -> List[str]:
r = random.random() < self._failure_rate
failed = r < self._failure_rate
partial_failed = r < self._partial_failure_ratio
if failed:
raise IOError("Spilling object failed intentionally for testing.")
elif partial_failed:
i = random.choice(range(len(object_refs)))
return super().spill_objects(object_refs[:i], owner_addresses)
else:
return super().spill_objects(object_refs, owner_addresses)
class SlowFileStorage(FileSystemStorage):
"""This class is for testing slow object spilling."""
def __init__(self, node_id: str, **kwargs):
super().__init__(node_id, **kwargs)
self._min_delay = 1
self._max_delay = 2
def spill_objects(self, object_refs, owner_addresses) -> List[str]:
delay = random.random() * (self._max_delay - self._min_delay) + self._min_delay
time.sleep(delay)
return super().spill_objects(object_refs, owner_addresses)
def setup_external_storage(config, node_id, session_name):
"""Setup the external storage according to the config."""
assert node_id is not None, "node_id should be provided."
global _external_storage
if config:
storage_type = config["type"]
if storage_type == "filesystem":
_external_storage = FileSystemStorage(node_id, **config["params"])
elif storage_type == "smart_open":
_external_storage = ExternalStorageSmartOpenImpl(
node_id, **config["params"]
)
elif storage_type == "mock_distributed_fs":
# This storage is used to unit test distributed external storages.
# TODO(sang): Delete it after introducing the mock S3 test.
_external_storage = FileSystemStorage(node_id, **config["params"])
elif storage_type == "unstable_fs":
# This storage is used to unit test unstable file system for fault
# tolerance.
_external_storage = UnstableFileStorage(node_id, **config["params"])
elif storage_type == "slow_fs":
# This storage is used to unit test slow filesystems.
_external_storage = SlowFileStorage(node_id, **config["params"])
else:
raise ValueError(f"Unknown external storage type: {storage_type}")
else:
_external_storage = NullStorage()
return _external_storage
def reset_external_storage():
global _external_storage
_external_storage = NullStorage()
def spill_objects(
object_refs: List[ObjectRef], owner_addresses: List[str]
) -> List[str]:
"""Spill objects to the external storage. Objects are specified
by their object refs.
Args:
object_refs: The list of the refs of the objects to be spilled.
owner_addresses: The owner addresses of the provided object refs.
Returns:
A list of keys corresponding to the input object refs.
"""
return _external_storage.spill_objects(object_refs, owner_addresses)
def restore_spilled_objects(
object_refs: List[ObjectRef], url_with_offset_list: List[str]
):
"""Restore objects from the external storage.
Args:
object_refs: List of object IDs (note that it is not ref).
url_with_offset_list: List of url_with_offset.
Returns:
The total number of bytes restored.
"""
return _external_storage.restore_spilled_objects(object_refs, url_with_offset_list)
def delete_spilled_objects(urls: List[str]):
"""Delete objects that are spilled to the external storage.
Args:
urls: URLs that store spilled object files.
"""
_external_storage.delete_spilled_objects(urls)
def _get_unique_spill_filename(object_refs: List[ObjectRef]) -> str:
"""Generate a unique spill file name.
Args:
object_refs: objects to be spilled in this file.
Returns:
A unique filename for the spilled object batch.
"""
return f"{uuid.uuid4().hex}-multi-{len(object_refs)}"
+750
View File
@@ -0,0 +1,750 @@
import dis
import hashlib
import importlib
import inspect
import json
import logging
import os
import sys
import threading
import time
import traceback
from collections import defaultdict, namedtuple
from typing import Callable, Optional
import ray
import ray._private.profiling as profiling
from ray import cloudpickle as pickle
from ray._common.serialization import pickle_dumps
from ray._private import ray_constants
from ray._private.inspect_util import (
is_class_method,
is_function_or_method,
is_static_method,
)
from ray._private.ray_constants import KV_NAMESPACE_FUNCTION_TABLE
from ray._private.utils import (
check_oversized_function,
ensure_str,
format_error_message,
)
from ray._raylet import (
WORKER_PROCESS_SETUP_HOOK_KEY_NAME_GCS,
JobID,
PythonFunctionDescriptor,
)
from ray.remote_function import RemoteFunction
from ray.util.tracing.tracing_helper import _inject_tracing_into_class
FunctionExecutionInfo = namedtuple(
"FunctionExecutionInfo", ["function", "function_name", "max_calls"]
)
ImportedFunctionInfo = namedtuple(
"ImportedFunctionInfo",
["job_id", "function_id", "function_name", "function", "module", "max_calls"],
)
"""FunctionExecutionInfo: A named tuple storing remote function information."""
logger = logging.getLogger(__name__)
def make_function_table_key(key_type: bytes, job_id: JobID, key: Optional[bytes]):
if key is None:
return b":".join([key_type, job_id.hex().encode()])
else:
return b":".join([key_type, job_id.hex().encode(), key])
def build_setup_hook_export_entry(
setup_func: Callable, job_id: JobID
) -> tuple[bytes, bytes, bytes]:
"""Compute the exported payload and GCS key for a setup hook callable.
Args:
setup_func: The setup hook function to export.
job_id: The job ID to export the setup hook for.
Returns:
A tuple of (pickled_function, function_id, key).
"""
pickled_function = pickle_dumps(
setup_func,
"Cannot serialize the worker_process_setup_hook " f"{setup_func.__name__}",
)
function_to_run_id = hashlib.shake_128(pickled_function).digest(
ray_constants.ID_SIZE
)
key = make_function_table_key(
# This value should match with gcs_function_manager.h.
# Otherwise, it won't be GC'ed.
WORKER_PROCESS_SETUP_HOOK_KEY_NAME_GCS.encode(),
# b"FunctionsToRun",
job_id,
function_to_run_id,
)
return pickled_function, function_to_run_id, key
class FunctionActorManager:
"""A class used to export/load remote functions and actors.
Attributes:
_worker: The associated worker that this manager related.
_functions_to_export: The remote functions to export when
the worker gets connected.
_actors_to_export: The actors to export when the worker gets
connected.
_function_execution_info: The function_id
and execution_info.
_num_task_executions: The function
execution times.
imported_actor_classes: The set of actor classes keys (format:
ActorClass:function_id) that are already in GCS.
"""
def __init__(self, worker: "ray._private.worker.Worker"):
"""Initialize FunctionActorManager.
Args:
worker: The worker this manager belongs to.
"""
self._worker = worker
self._functions_to_export = []
self._actors_to_export = []
# This field is a dictionary that maps function IDs
# to a FunctionExecutionInfo object. This should only be used on
# workers that execute remote functions.
self._function_execution_info = defaultdict(lambda: {})
self._num_task_executions = defaultdict(lambda: {})
# A set of all of the actor class keys that have been imported by the
# import thread. It is safe to convert this worker into an actor of
# these types.
self.imported_actor_classes = set()
self._loaded_actor_classes = {}
# Deserialize an ActorHandle will call load_actor_class(). If a
# function closure captured an ActorHandle, the deserialization of the
# function will be:
# -> fetch_and_register_remote_function (acquire lock)
# -> _load_actor_class_from_gcs (acquire lock, too)
# So, the lock should be a reentrant lock.
self.lock = threading.RLock()
self.execution_infos = {}
# This is the counter to keep track of how many keys have already
# been exported so that we can find next key quicker.
self._num_exported = 0
# This is to protect self._num_exported when doing exporting
self._export_lock = threading.Lock()
def increase_task_counter(self, function_descriptor):
function_id = function_descriptor.function_id
self._num_task_executions[function_id] += 1
def get_task_counter(self, function_descriptor):
function_id = function_descriptor.function_id
return self._num_task_executions[function_id]
def compute_collision_identifier(self, function_or_class: Callable) -> bytes:
"""The identifier is used to detect excessive duplicate exports.
The identifier is used to determine when the same function or class is
exported many times. This can yield false positives.
Args:
function_or_class: The function or class to compute an identifier
for.
Returns:
The identifier. Note that different functions or classes can give
rise to same identifier. However, the same function should
hopefully always give rise to the same identifier. TODO(rkn):
verify if this is actually the case. Note that if the
identifier is incorrect in any way, then we may give warnings
unnecessarily or fail to give warnings, but the application's
behavior won't change.
"""
import io
string_file = io.StringIO()
dis.dis(function_or_class, file=string_file, depth=2)
collision_identifier = function_or_class.__name__ + ":" + string_file.getvalue()
# Return a hash of the identifier in case it is too large.
return hashlib.sha256(collision_identifier.encode("utf-8")).digest()
def load_function_or_class_from_local(self, module_name, function_or_class_name):
"""Try to load a function or class in the module from local."""
module = importlib.import_module(module_name)
parts = [part for part in function_or_class_name.split(".") if part]
object = module
try:
for part in parts:
object = getattr(object, part)
return object
except Exception:
return None
def export_setup_func(
self, setup_func: Callable, timeout: Optional[int] = None
) -> bytes:
"""Export the setup hook function and return the key."""
pickled_function, function_to_run_id, key = build_setup_hook_export_entry(
setup_func, self._worker.current_job_id.binary()
)
check_oversized_function(
pickled_function, setup_func.__name__, "function", self._worker
)
try:
self._worker.gcs_client.internal_kv_put(
key,
pickle.dumps(
{
"job_id": self._worker.current_job_id.binary(),
"function_id": function_to_run_id,
"function": pickled_function,
}
),
# overwrite
True,
ray_constants.KV_NAMESPACE_FUNCTION_TABLE,
timeout=timeout,
)
except Exception as e:
logger.exception(
"Failed to export the setup hook " f"{setup_func.__name__}."
)
raise e
return key
def export(self, remote_function: RemoteFunction) -> None:
"""Pickle a remote function and export it to redis.
Args:
remote_function: the RemoteFunction object.
"""
if self._worker.load_code_from_local:
function_descriptor = remote_function._function_descriptor
module_name, function_name = (
function_descriptor.module_name,
function_descriptor.function_name,
)
# If the function is dynamic, we still export it to GCS
# even if load_code_from_local is set True.
if (
self.load_function_or_class_from_local(module_name, function_name)
is not None
):
return
function = remote_function._function
pickled_function = remote_function._pickled_function
check_oversized_function(
pickled_function,
remote_function._function_name,
"remote function",
self._worker,
)
key = make_function_table_key(
b"RemoteFunction",
self._worker.current_job_id,
remote_function._function_descriptor.function_id.binary(),
)
if self._worker.gcs_client.internal_kv_exists(key, KV_NAMESPACE_FUNCTION_TABLE):
return
val = pickle.dumps(
{
"job_id": self._worker.current_job_id.binary(),
"function_id": remote_function._function_descriptor.function_id.binary(), # noqa: E501
"function_name": remote_function._function_name,
"module": function.__module__,
"function": pickled_function,
"collision_identifier": self.compute_collision_identifier(function),
"max_calls": remote_function._max_calls,
}
)
self._worker.gcs_client.internal_kv_put(
key, val, True, KV_NAMESPACE_FUNCTION_TABLE
)
def fetch_registered_method(
self, key: str, timeout: Optional[int] = None
) -> Optional[ImportedFunctionInfo]:
vals = self._worker.gcs_client.internal_kv_get(
key, KV_NAMESPACE_FUNCTION_TABLE, timeout=timeout
)
if vals is None:
return None
else:
vals = pickle.loads(vals)
fields = [
"job_id",
"function_id",
"function_name",
"function",
"module",
"max_calls",
]
return ImportedFunctionInfo._make(vals.get(field) for field in fields)
def fetch_and_register_remote_function(self, key):
"""Import a remote function."""
remote_function_info = self.fetch_registered_method(key)
if not remote_function_info:
return False
(
job_id_str,
function_id_str,
function_name,
serialized_function,
module,
max_calls,
) = remote_function_info
function_id = ray.FunctionID(function_id_str)
job_id = ray.JobID(job_id_str)
max_calls = int(max_calls)
# This function is called by ImportThread. This operation needs to be
# atomic. Otherwise, there is race condition. Another thread may use
# the temporary function above before the real function is ready.
with self.lock:
self._num_task_executions[function_id] = 0
try:
function = pickle.loads(serialized_function)
except Exception:
# If an exception was thrown when the remote function was
# imported, we record the traceback and notify the scheduler
# of the failure.
traceback_str = format_error_message(traceback.format_exc())
def f(*args, **kwargs):
raise RuntimeError(
"The remote function failed to import on the "
"worker. This may be because needed library "
"dependencies are not installed in the worker "
"environment or cannot be found from sys.path "
f"{sys.path}:\n\n{traceback_str}"
)
# Use a placeholder method when function pickled failed
self._function_execution_info[function_id] = FunctionExecutionInfo(
function=f, function_name=function_name, max_calls=max_calls
)
# Log the error message. Log at DEBUG level to avoid overly
# spamming the log on import failure. The user gets the error
# via the RuntimeError message above.
logger.debug(
"Failed to unpickle the remote function "
f"'{function_name}' with "
f"function ID {function_id.hex()}. "
f"Job ID:{job_id}."
f"Traceback:\n{traceback_str}. "
)
else:
# The below line is necessary. Because in the driver process,
# if the function is defined in the file where the python
# script was started from, its module is `__main__`.
# However in the worker process, the `__main__` module is a
# different module, which is `default_worker.py`
function.__module__ = module
self._function_execution_info[function_id] = FunctionExecutionInfo(
function=function, function_name=function_name, max_calls=max_calls
)
return True
def get_execution_info(
self, job_id: JobID, function_descriptor: PythonFunctionDescriptor
) -> FunctionExecutionInfo:
"""Get the FunctionExecutionInfo of a remote function.
Args:
job_id: ID of the job that the function belongs to.
function_descriptor: The FunctionDescriptor of the function to get.
Returns:
A FunctionExecutionInfo object.
"""
function_id = function_descriptor.function_id
# If the function has already been loaded,
# There's no need to load again
if function_id in self._function_execution_info:
return self._function_execution_info[function_id]
if self._worker.load_code_from_local:
# Load function from local code.
if not function_descriptor.is_actor_method():
# If the function is not able to be loaded,
# try to load it from GCS,
# even if load_code_from_local is set True
if self._load_function_from_local(function_descriptor) is True:
return self._function_execution_info[function_id]
# Load function from GCS.
# Wait until the function to be executed has actually been
# registered on this worker. We will push warnings to the user if
# we spend too long in this loop.
# The driver function may not be found in sys.path. Try to load
# the function from GCS.
with profiling.profile("wait_for_function"):
self._wait_for_function(function_descriptor, job_id)
try:
function_id = function_descriptor.function_id
info = self._function_execution_info[function_id]
except KeyError as e:
message = (
"Error occurs in get_execution_info: "
"job_id: %s, function_descriptor: %s. Message: %s"
% (job_id, function_descriptor, e)
)
raise KeyError(message)
return info
def _load_function_from_local(self, function_descriptor):
assert not function_descriptor.is_actor_method()
function_id = function_descriptor.function_id
module_name, function_name = (
function_descriptor.module_name,
function_descriptor.function_name,
)
object = self.load_function_or_class_from_local(module_name, function_name)
if object is not None:
# Directly importing from local may break function with dynamic ray.remote,
# such as the _start_controller function utilized for the Ray service.
if isinstance(object, RemoteFunction):
function = object._function
else:
function = object
self._function_execution_info[function_id] = FunctionExecutionInfo(
function=function,
function_name=function_name,
max_calls=0,
)
self._num_task_executions[function_id] = 0
return True
else:
return False
def _wait_for_function(
self,
function_descriptor: PythonFunctionDescriptor,
job_id: str,
timeout: float = 10,
):
"""Wait until the function to be executed is present on this worker.
This method will simply loop until the import thread has imported the
relevant function. If we spend too long in this loop, that may indicate
a problem somewhere and we will push an error message to the user.
If this worker is an actor, then this will wait until the actor has
been defined.
Args:
function_descriptor: The FunctionDescriptor of the function that
we want to execute.
job_id: The ID of the job to push the error message to
if this times out.
timeout: Seconds to wait before pushing a warning to the user.
"""
start_time = time.time()
# Only send the warning once.
warning_sent = False
while True:
with self.lock:
if self._worker.actor_id.is_nil():
if function_descriptor.function_id in self._function_execution_info:
break
else:
key = make_function_table_key(
b"RemoteFunction",
job_id,
function_descriptor.function_id.binary(),
)
if self.fetch_and_register_remote_function(key) is True:
break
else:
assert not self._worker.actor_id.is_nil()
# Actor loading will happen when execute_task is called.
assert self._worker.actor_id in self._worker.actors
break
if time.time() - start_time > timeout:
warning_message = (
"This worker was asked to execute a function "
f"that has not been registered ({function_descriptor}, "
f"node={self._worker.node_ip_address}, "
f"worker_id={self._worker.worker_id.hex()}, "
f"pid={os.getpid()}). You may have to restart Ray."
)
if not warning_sent:
logger.error(warning_message)
ray._private.utils.push_error_to_driver(
self._worker,
ray_constants.WAIT_FOR_FUNCTION_PUSH_ERROR,
warning_message,
job_id=job_id,
)
warning_sent = True
time.sleep(0.001)
def export_actor_class(
self, Class, actor_creation_function_descriptor, actor_method_names
):
if self._worker.load_code_from_local:
module_name, class_name = (
actor_creation_function_descriptor.module_name,
actor_creation_function_descriptor.class_name,
)
# If the class is dynamic, we still export it to GCS
# even if load_code_from_local is set True.
if (
self.load_function_or_class_from_local(module_name, class_name)
is not None
):
return
# `current_job_id` shouldn't be NIL, unless:
# 1) This worker isn't an actor;
# 2) And a previous task started a background thread, which didn't
# finish before the task finished, and still uses Ray API
# after that.
assert not self._worker.current_job_id.is_nil(), (
"You might have started a background thread in a non-actor "
"task, please make sure the thread finishes before the "
"task finishes."
)
job_id = self._worker.current_job_id
key = make_function_table_key(
b"ActorClass",
job_id,
actor_creation_function_descriptor.function_id.binary(),
)
serialized_actor_class = pickle_dumps(
Class,
f"Could not serialize the actor class "
f"{actor_creation_function_descriptor.repr}",
)
actor_class_info = {
"class_name": actor_creation_function_descriptor.class_name.split(".")[-1],
"module": actor_creation_function_descriptor.module_name,
"class": serialized_actor_class,
"job_id": job_id.binary(),
"collision_identifier": self.compute_collision_identifier(Class),
"actor_method_names": json.dumps(list(actor_method_names)),
}
check_oversized_function(
actor_class_info["class"],
actor_class_info["class_name"],
"actor",
self._worker,
)
self._worker.gcs_client.internal_kv_put(
key, pickle.dumps(actor_class_info), True, KV_NAMESPACE_FUNCTION_TABLE
)
# TODO(rkn): Currently we allow actor classes to be defined
# within tasks. I tried to disable this, but it may be necessary
# because of https://github.com/ray-project/ray/issues/1146.
def load_actor_class(
self,
job_id: JobID,
actor_creation_function_descriptor: PythonFunctionDescriptor,
) -> type:
"""Load the actor class.
Args:
job_id: job ID of the actor.
actor_creation_function_descriptor: Function descriptor of
the actor constructor.
Returns:
The actor class.
"""
function_id = actor_creation_function_descriptor.function_id
# Check if the actor class already exists in the cache.
actor_class = self._loaded_actor_classes.get(function_id, None)
if actor_class is None:
# Load actor class.
if self._worker.load_code_from_local:
# Load actor class from local code first.
actor_class = self._load_actor_class_from_local(
actor_creation_function_descriptor
)
# If the actor is unable to be loaded
# from local, try to load it
# from GCS even if load_code_from_local is set True
if actor_class is None:
actor_class = self._load_actor_class_from_gcs(
job_id, actor_creation_function_descriptor
)
else:
# Load actor class from GCS.
actor_class = self._load_actor_class_from_gcs(
job_id, actor_creation_function_descriptor
)
# Re-inject tracing into the loaded class. This is necessary because
# cloudpickle doesn't preserve __signature__ attributes on module-level
# functions. When a class is pickled and unpickled, user-defined methods
# are looked up from the module, losing the __signature__ that was set by
# _inject_tracing_into_class during actor creation. Re-injecting tracing
# ensures the method signatures include _ray_trace_ctx when tracing is
# enabled, matching the behavior expected by _tracing_actor_method_invocation.
_inject_tracing_into_class(actor_class)
# Save the loaded actor class in cache.
self._loaded_actor_classes[function_id] = actor_class
# Generate execution info for the methods of this actor class.
module_name = actor_creation_function_descriptor.module_name
actor_class_name = actor_creation_function_descriptor.class_name
actor_methods = inspect.getmembers(
actor_class, predicate=is_function_or_method
)
for actor_method_name, actor_method in actor_methods:
# Actor creation function descriptor use a unique function
# hash to solve actor name conflict. When constructing an
# actor, the actor creation function descriptor will be the
# key to find __init__ method execution info. So, here we
# use actor creation function descriptor as method descriptor
# for generating __init__ method execution info.
if actor_method_name == "__init__":
method_descriptor = actor_creation_function_descriptor
else:
method_descriptor = PythonFunctionDescriptor(
module_name, actor_method_name, actor_class_name
)
method_id = method_descriptor.function_id
executor = self._make_actor_method_executor(
actor_method_name, actor_method
)
self._function_execution_info[method_id] = FunctionExecutionInfo(
function=executor,
function_name=actor_method_name,
max_calls=0,
)
self._num_task_executions[method_id] = 0
self._num_task_executions[function_id] = 0
return actor_class
def _load_actor_class_from_local(self, actor_creation_function_descriptor):
"""Load actor class from local code."""
module_name, class_name = (
actor_creation_function_descriptor.module_name,
actor_creation_function_descriptor.class_name,
)
object = self.load_function_or_class_from_local(module_name, class_name)
if object is not None:
if isinstance(object, ray.actor.ActorClass):
return object.__ray_metadata__.modified_class
else:
return ray.actor._modify_class(object)
else:
return None
def _create_fake_actor_class(
self, actor_class_name, actor_method_names, traceback_str
):
class TemporaryActor:
async def __dummy_method(self):
"""Dummy method for this fake actor class to work for async actors.
Without this method, this temporary actor class fails to initialize
if the original actor class was async."""
pass
def temporary_actor_method(*args, **kwargs):
raise RuntimeError(
f"The actor with name {actor_class_name} "
"failed to import on the worker. This may be because "
"needed library dependencies are not installed in the "
f"worker environment:\n\n{traceback_str}"
)
for method in actor_method_names:
setattr(TemporaryActor, method, temporary_actor_method)
return TemporaryActor
def _load_actor_class_from_gcs(self, job_id, actor_creation_function_descriptor):
"""Load actor class from GCS."""
key = make_function_table_key(
b"ActorClass",
job_id,
actor_creation_function_descriptor.function_id.binary(),
)
# Fetch raw data from GCS.
vals = self._worker.gcs_client.internal_kv_get(key, KV_NAMESPACE_FUNCTION_TABLE)
fields = ["job_id", "class_name", "module", "class", "actor_method_names"]
if vals is None:
vals = {}
else:
vals = pickle.loads(vals)
(job_id_str, class_name, module, pickled_class, actor_method_names) = (
vals.get(field) for field in fields
)
class_name = ensure_str(class_name)
module_name = ensure_str(module)
job_id = ray.JobID(job_id_str)
actor_method_names = json.loads(ensure_str(actor_method_names))
actor_class = None
try:
with self.lock:
actor_class = pickle.loads(pickled_class)
except Exception:
logger.debug("Failed to load actor class %s.", class_name)
# If an exception was thrown when the actor was imported, we record
# the traceback and notify the scheduler of the failure.
traceback_str = format_error_message(traceback.format_exc())
# The actor class failed to be unpickled, create a fake actor
# class instead (just to produce error messages and to prevent
# the driver from hanging).
actor_class = self._create_fake_actor_class(
class_name, actor_method_names, traceback_str
)
# The below line is necessary. Because in the driver process,
# if the function is defined in the file where the python script
# was started from, its module is `__main__`.
# However in the worker process, the `__main__` module is a
# different module, which is `default_worker.py`
actor_class.__module__ = module_name
return actor_class
def _make_actor_method_executor(self, method_name: str, method: Callable):
"""Make an executor that wraps a user-defined actor method.
The wrapped method updates the worker's internal state and performs any
necessary checkpointing operations.
Args:
method_name: The name of the actor method.
method: The actor method to wrap. This should be a
method defined on the actor class and should therefore take an
instance of the actor as the first argument.
Returns:
A function that executes the given actor method on the worker's
stored instance of the actor. The function also updates the
worker's internal state to record the executed method.
"""
def actor_method_executor(__ray_actor, *args, **kwargs):
# Execute the assigned method.
is_bound = is_class_method(method) or is_static_method(
type(__ray_actor), method_name
)
if is_bound:
return method(*args, **kwargs)
else:
return method(__ray_actor, *args, **kwargs)
# Set method_name and method as attributes to the executor closure
# so we can make decision based on these attributes in task executor.
# Precisely, asyncio support requires to know whether:
# - the method is a ray internal method: starts with __ray
# - the method is a coroutine function: defined by async def
actor_method_executor.name = method_name
actor_method_executor.method = method
return actor_method_executor
+51
View File
@@ -0,0 +1,51 @@
import gc
import logging
import threading
import time
from typing import Callable, Optional
logger = logging.getLogger(__name__)
class PythonGCThread(threading.Thread):
"""A background thread that triggers Python garbage collection.
This thread waits for GC events from CoreWorker and triggers `gc.collect()` when
when requested."""
def __init__(self, *, gc_collect_func: Optional[Callable] = None):
logger.debug("Starting Python GC thread")
super().__init__(name="PythonGCThread", daemon=True)
self._should_exit = False
self._gc_event = threading.Event()
# Sets the gc_collect_func (only for testing), defaults to gc.collect
self._gc_collect_func = gc_collect_func or gc.collect
def trigger_gc(self) -> None:
self._gc_event.set()
def run(self):
while not self._should_exit:
self._gc_event.wait()
self._gc_event.clear()
if self._should_exit:
break
try:
start = time.monotonic()
num_freed = self._gc_collect_func()
if num_freed > 0:
logger.debug(
"gc.collect() freed {} refs in {} seconds".format(
num_freed, time.monotonic() - start
)
)
except Exception as e:
logger.error(f"Error during GC: {e}")
def stop(self):
logger.debug("Stopping Python GC thread")
self._should_exit = True
self._gc_event.set()
self.join()
+293
View File
@@ -0,0 +1,293 @@
import asyncio
import logging
import random
from collections import deque
from typing import List, Optional, Tuple
import grpc
from grpc import aio as aiogrpc
import ray._private.gcs_utils as gcs_utils
from ray._common.utils import get_or_create_event_loop
from ray.core.generated import (
gcs_pb2,
gcs_service_pb2,
gcs_service_pb2_grpc,
pubsub_pb2,
)
logger = logging.getLogger(__name__)
_OBSERVABILITY_PUBSUB_CHANNELS = (
pubsub_pb2.RAY_ERROR_INFO_CHANNEL,
pubsub_pb2.RAY_LOG_CHANNEL,
pubsub_pb2.RAY_NODE_RESOURCE_USAGE_CHANNEL,
)
class _SubscriberBase:
def __init__(self, worker_id: bytes = None):
self._worker_id = worker_id
# self._subscriber_id needs to match the binary format of a random
# SubscriberID / UniqueID, which is 28 (kUniqueIDSize) random bytes.
self._subscriber_id = bytes(bytearray(random.getrandbits(8) for _ in range(28)))
self._last_batch_size = 0
self._max_processed_sequence_id = 0
self._publisher_id = b""
# Batch size of the result from last poll. Used to indicate whether the
# subscriber can keep up.
@property
def last_batch_size(self):
return self._last_batch_size
def _subscribe_request(self, channel):
cmd = pubsub_pb2.Command(channel_type=channel, subscribe_message={})
req = gcs_service_pb2.GcsSubscriberCommandBatchRequest(
subscriber_id=self._subscriber_id, sender_id=self._worker_id, commands=[cmd]
)
return req
def _poll_request(self):
return gcs_service_pb2.GcsSubscriberPollRequest(
subscriber_id=self._subscriber_id,
max_processed_sequence_id=self._max_processed_sequence_id,
publisher_id=self._publisher_id,
)
def _unsubscribe_request(self, channels):
req = gcs_service_pb2.GcsSubscriberCommandBatchRequest(
subscriber_id=self._subscriber_id, sender_id=self._worker_id, commands=[]
)
for channel in channels:
req.commands.append(
pubsub_pb2.Command(channel_type=channel, unsubscribe_message={})
)
return req
@staticmethod
def _should_terminate_polling(e: grpc.RpcError) -> None:
# Caller only expects polling to be terminated after deadline exceeded.
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
return True
# Could be a temporary connection issue. Suppress error.
# TODO: reconnect GRPC channel?
if e.code() == grpc.StatusCode.UNAVAILABLE:
return True
return False
class _AioSubscriber(_SubscriberBase):
"""Async io subscriber to GCS.
Usage example common to Aio subscribers:
subscriber = GcsAioXxxSubscriber(address="...")
await subscriber.subscribe()
while running:
...... = await subscriber.poll()
......
await subscriber.close()
"""
def __init__(
self,
pubsub_channel_type,
worker_id: bytes = None,
address: str = None,
channel: aiogrpc.Channel = None,
):
super().__init__(worker_id)
if address:
assert channel is None, "address and channel cannot both be specified"
channel = gcs_utils.create_gcs_channel(address, aio=True)
else:
assert channel is not None, "One of address and channel must be specified"
if pubsub_channel_type in _OBSERVABILITY_PUBSUB_CHANNELS:
self._stub = gcs_service_pb2_grpc.ObservabilityPubSubServiceStub(channel)
else:
self._stub = gcs_service_pb2_grpc.ControlPlanePubSubGcsServiceStub(channel)
# Type of the channel.
self._channel = pubsub_channel_type
# A queue of received PubMessage.
self._queue = deque()
# Indicates whether the subscriber has closed.
self._close = asyncio.Event()
async def subscribe(self) -> None:
"""Registers a subscription for the subscriber's channel type.
Before the registration, published messages in the channel will not be
saved for the subscriber.
"""
if self._close.is_set():
return
req = self._subscribe_request(self._channel)
await self._stub.GcsSubscriberCommandBatch(req, timeout=30)
async def _poll_call(self, req, timeout=None):
# Wrap GRPC _AioCall as a coroutine.
return await self._stub.GcsSubscriberPoll(req, timeout=timeout)
async def _poll(self, timeout=None) -> None:
while len(self._queue) == 0:
req = self._poll_request()
poll = get_or_create_event_loop().create_task(
self._poll_call(req, timeout=timeout)
)
close = get_or_create_event_loop().create_task(self._close.wait())
done, others = await asyncio.wait(
[poll, close], timeout=timeout, return_when=asyncio.FIRST_COMPLETED
)
# Cancel the other task if needed to prevent memory leak.
other_task = others.pop()
if not other_task.done():
other_task.cancel()
if poll not in done or close in done:
# Request timed out or subscriber closed.
break
try:
self._last_batch_size = len(poll.result().pub_messages)
if poll.result().publisher_id != self._publisher_id:
if self._publisher_id != b"":
logger.debug(
f"replied publisher_id {poll.result().publisher_id} "
f"different from {self._publisher_id}, this should "
"only happen during gcs failover."
)
self._publisher_id = poll.result().publisher_id
self._max_processed_sequence_id = 0
for msg in poll.result().pub_messages:
if msg.sequence_id <= self._max_processed_sequence_id:
logger.warning(f"Ignoring out of order message {msg}")
continue
self._max_processed_sequence_id = msg.sequence_id
self._queue.append(msg)
except grpc.RpcError as e:
if self._should_terminate_polling(e):
return
raise
async def close(self) -> None:
"""Closes the subscriber and its active subscription."""
# Mark close to terminate inflight polling and prevent future requests.
if self._close.is_set():
return
self._close.set()
req = self._unsubscribe_request(channels=[self._channel])
try:
await self._stub.GcsSubscriberCommandBatch(req, timeout=5)
except Exception:
pass
self._stub = None
class GcsAioResourceUsageSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(
pubsub_pb2.RAY_NODE_RESOURCE_USAGE_CHANNEL, worker_id, address, channel
)
async def poll(self, timeout: Optional[float] = None) -> Tuple[bytes, str]:
"""Polls for new resource usage message.
Args:
timeout: Optional timeout in seconds for the poll request.
Returns:
A tuple of string reporter ID and resource usage json string.
"""
await self._poll(timeout=timeout)
return self._pop_resource_usage(self._queue)
@staticmethod
def _pop_resource_usage(queue):
if len(queue) == 0:
return None, None
msg = queue.popleft()
return msg.key_id.decode(), msg.node_resource_usage_message.json
class GcsAioActorSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(pubsub_pb2.GCS_ACTOR_CHANNEL, worker_id, address, channel)
@property
def queue_size(self):
return len(self._queue)
async def poll(
self, batch_size: int, timeout: Optional[float] = None
) -> List[Tuple[bytes, gcs_pb2.ActorTableData]]:
"""Polls for new actor message.
Args:
batch_size: Maximum number of messages to return.
timeout: Optional timeout in seconds for the poll request.
Returns:
A list of tuples of binary actor ID and actor table data.
"""
await self._poll(timeout=timeout)
return self._pop_actors(self._queue, batch_size=batch_size)
@staticmethod
def _pop_actors(queue, batch_size):
if len(queue) == 0:
return []
popped = 0
msgs = []
while len(queue) > 0 and popped < batch_size:
msg = queue.popleft()
msgs.append((msg.key_id, msg.actor_message))
popped += 1
return msgs
class GcsAioNodeInfoSubscriber(_AioSubscriber):
def __init__(
self,
worker_id: bytes = None,
address: str = None,
channel: grpc.Channel = None,
):
super().__init__(pubsub_pb2.GCS_NODE_INFO_CHANNEL, worker_id, address, channel)
async def poll(
self, batch_size: int, timeout: Optional[float] = None
) -> List[Tuple[bytes, gcs_pb2.GcsNodeInfo]]:
"""Polls for new node info message.
Args:
batch_size: Maximum number of messages to return.
timeout: Optional timeout in seconds for the poll request.
Returns:
A list of tuples of (node_id, GcsNodeInfo).
"""
await self._poll(timeout=timeout)
return self._pop_node_infos(self._queue, batch_size=batch_size)
@staticmethod
def _pop_node_infos(queue, batch_size):
if len(queue) == 0:
return []
popped = 0
msgs = []
while len(queue) > 0 and popped < batch_size:
msg = queue.popleft()
msgs.append((msg.key_id, msg.node_info_message))
popped += 1
return msgs
+159
View File
@@ -0,0 +1,159 @@
import logging
from typing import Optional
from ray._private import ray_constants
from ray.core.generated.common_pb2 import ErrorType, JobConfig
from ray.core.generated.gcs_pb2 import (
ActorTableData,
AvailableResources,
ErrorTableData,
GcsEntry,
GcsNodeInfo,
JobTableData,
PlacementGroupTableData,
PubSubMessage,
ResourceDemand,
ResourceLoad,
ResourcesData,
ResourceUsageBatchData,
TablePrefix,
TablePubsub,
TaskEvents,
TotalResources,
WorkerTableData,
)
logger = logging.getLogger(__name__)
__all__ = [
"ActorTableData",
"GcsNodeInfo",
"AvailableResources",
"TotalResources",
"JobTableData",
"JobConfig",
"ErrorTableData",
"ErrorType",
"GcsEntry",
"ResourceUsageBatchData",
"ResourcesData",
"TablePrefix",
"TablePubsub",
"TaskEvents",
"ResourceDemand",
"ResourceLoad",
"PubSubMessage",
"WorkerTableData",
"PlacementGroupTableData",
]
WORKER = 0
DRIVER = 1
# Cap messages at 512MB
_MAX_MESSAGE_LENGTH = 512 * 1024 * 1024
# Send keepalive every 60s
_GRPC_KEEPALIVE_TIME_MS = 60 * 1000
# Keepalive should be replied < 60s
_GRPC_KEEPALIVE_TIMEOUT_MS = 60 * 1000
# Also relying on these defaults:
# grpc.keepalive_permit_without_calls=0: No keepalive without inflight calls.
# grpc.use_local_subchannel_pool=0: Subchannels are shared.
_GRPC_OPTIONS = [
*ray_constants.GLOBAL_GRPC_OPTIONS,
("grpc.max_send_message_length", _MAX_MESSAGE_LENGTH),
("grpc.max_receive_message_length", _MAX_MESSAGE_LENGTH),
("grpc.keepalive_time_ms", _GRPC_KEEPALIVE_TIME_MS),
("grpc.keepalive_timeout_ms", _GRPC_KEEPALIVE_TIMEOUT_MS),
]
def create_gcs_channel(address: str, aio: bool = False):
"""Returns a GRPC channel to GCS.
Args:
address: GCS address string, e.g. ip:port
aio: Whether using grpc.aio
Returns:
grpc.Channel or grpc.aio.Channel to GCS
"""
from ray._private.grpc_utils import init_grpc_channel
return init_grpc_channel(address, options=_GRPC_OPTIONS, asynchronous=aio)
class GcsChannel:
def __init__(self, gcs_address: Optional[str] = None, aio: bool = False):
self._gcs_address = gcs_address
self._aio = aio
@property
def address(self):
return self._gcs_address
def connect(self):
# GCS server uses a cached port, so it should use the same port after
# restarting. This means GCS address should stay the same for the
# lifetime of the Ray cluster.
self._channel = create_gcs_channel(self._gcs_address, self._aio)
def channel(self):
return self._channel
def cleanup_redis_storage(
host: str,
port: int,
password: str,
use_ssl: bool,
storage_namespace: str,
username: Optional[str] = None,
):
"""This function is used to cleanup the GCS storage in Redis.
It supports Redis in cluster and non-cluster modes.
Args:
host: The Redis host address.
port: The Redis port.
password: The Redis password.
use_ssl: Whether to encrypt the connection.
storage_namespace: The namespace of the storage to be deleted.
username: The Redis username.
Returns:
The result of deleting the GCS key prefix from the Redis storage.
"""
from ray._raylet import del_key_prefix_from_storage # type: ignore
if not isinstance(host, str):
raise ValueError("Host must be a string")
if username is None:
username = ""
if not isinstance(username, str):
raise ValueError("Username must be a string")
if not isinstance(password, str):
raise ValueError("Password must be a string")
if port < 0:
raise ValueError(f"Invalid port: {port}")
if not isinstance(use_ssl, bool):
raise TypeError("use_ssl must be a boolean")
if not isinstance(storage_namespace, str):
raise ValueError("storage namespace must be a string")
# Right now, GCS stores all data in multiple hashes with keys prefixed by
# storage_namespace. So we only need to delete the specific key prefix to cleanup
# the cluster's data.
# Note this deletes all keys with prefix `RAY{key_prefix}@`, not `{key_prefix}`.
return del_key_prefix_from_storage(
host, port, username, password, use_ssl, storage_namespace
)
+155
View File
@@ -0,0 +1,155 @@
import os
from concurrent import futures
from typing import Any, Optional, Sequence, Tuple
import grpc
from grpc import aio as aiogrpc
import ray
from ray._common.tls_utils import load_certs_from_env
from ray._private.authentication import authentication_utils
def init_grpc_channel(
address: str,
options: Optional[Sequence[Tuple[str, Any]]] = None,
asynchronous: bool = False,
credentials: Optional[grpc.ChannelCredentials] = None,
):
"""Create a gRPC channel with authentication interceptors if token auth is enabled.
This function handles:
- TLS configuration via RAY_USE_TLS environment variable or custom credentials
- Authentication interceptors when token auth is enabled
- Keepalive settings from Ray config
- Both synchronous and asynchronous channels
Args:
address: The gRPC server address (host:port)
options: Optional gRPC channel options as sequence of (key, value) tuples
asynchronous: If True, create async channel; otherwise sync
credentials: Optional custom gRPC credentials for TLS. If provided, takes
precedence over RAY_USE_TLS environment variable.
Returns:
grpc.Channel or grpc.aio.Channel: Configured gRPC channel with interceptors
"""
grpc_module = aiogrpc if asynchronous else grpc
options = options or []
options_dict = dict(options)
options_dict["grpc.keepalive_time_ms"] = options_dict.get(
"grpc.keepalive_time_ms", ray._config.grpc_client_keepalive_time_ms()
)
options_dict["grpc.keepalive_timeout_ms"] = options_dict.get(
"grpc.keepalive_timeout_ms", ray._config.grpc_client_keepalive_timeout_ms()
)
options = options_dict.items()
# Build interceptors list
interceptors = []
if authentication_utils.is_token_auth_enabled():
from ray._private.authentication.grpc_authentication_client_interceptor import (
SyncAuthenticationMetadataClientInterceptor,
get_async_auth_interceptors,
)
if asynchronous:
interceptors.extend(get_async_auth_interceptors())
else:
interceptors.append(SyncAuthenticationMetadataClientInterceptor())
# Determine channel type and credentials
if credentials is not None:
# Use provided custom credentials (takes precedence)
channel_creator = grpc_module.secure_channel
base_args = (address, credentials)
elif os.environ.get("RAY_USE_TLS", "0").lower() in ("1", "true"):
# Use TLS from environment variables
server_cert_chain, private_key, ca_cert = load_certs_from_env()
tls_credentials = grpc.ssl_channel_credentials(
certificate_chain=server_cert_chain,
private_key=private_key,
root_certificates=ca_cert,
)
channel_creator = grpc_module.secure_channel
base_args = (address, tls_credentials)
else:
# Insecure channel
channel_creator = grpc_module.insecure_channel
base_args = (address,)
# Create channel (async channels get interceptors in constructor, sync via intercept_channel)
if asynchronous:
channel = channel_creator(
*base_args, options=options, interceptors=interceptors
)
else:
channel = channel_creator(*base_args, options=options)
if interceptors:
channel = grpc.intercept_channel(channel, *interceptors)
return channel
def create_grpc_server_with_interceptors(
max_workers: Optional[int] = None,
thread_name_prefix: str = "grpc_server",
options: Optional[Sequence[Tuple[str, Any]]] = None,
asynchronous: bool = False,
):
"""Create a gRPC server with authentication interceptors if token auth is enabled.
This function handles:
- Authentication interceptors when token auth is enabled
- Both synchronous and asynchronous servers
- Thread pool configuration for sync servers
Args:
max_workers: Max thread pool workers (required for sync, ignored for async)
thread_name_prefix: Thread name prefix for sync thread pool
options: Optional gRPC server options as sequence of (key, value) tuples
asynchronous: If True, create async server; otherwise sync
Returns:
grpc.Server or grpc.aio.Server: Configured gRPC server with interceptors
"""
grpc_module = aiogrpc if asynchronous else grpc
# Build interceptors list
interceptors = []
if authentication_utils.is_token_auth_enabled():
if asynchronous:
from ray._private.authentication.grpc_authentication_server_interceptor import (
AsyncAuthenticationServerInterceptor,
)
interceptors.append(AsyncAuthenticationServerInterceptor())
else:
from ray._private.authentication.grpc_authentication_server_interceptor import (
SyncAuthenticationServerInterceptor,
)
interceptors.append(SyncAuthenticationServerInterceptor())
# Create server
if asynchronous:
server = grpc_module.server(
interceptors=interceptors if interceptors else None,
options=options,
)
else:
if max_workers is None:
raise ValueError("max_workers is required for synchronous gRPC servers")
executor = futures.ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix=thread_name_prefix,
)
server = grpc_module.server(
executor,
interceptors=interceptors if interceptors else None,
options=options,
)
return server
+52
View File
@@ -0,0 +1,52 @@
import inspect
def is_cython(obj):
"""Check if an object is a Cython function or method"""
# TODO(suo): We could split these into two functions, one for Cython
# functions and another for Cython methods.
# TODO(suo): There doesn't appear to be a Cython function 'type' we can
# check against via isinstance. Please correct me if I'm wrong.
def check_cython(x):
return type(x).__name__ == "cython_function_or_method"
# Check if function or method, respectively
return check_cython(obj) or (
hasattr(obj, "__func__") and check_cython(obj.__func__)
)
def is_function_or_method(obj: object) -> bool:
"""Check if an object is a function or method.
Args:
obj: The Python object in question.
Returns:
True if the object is an function or method.
"""
return inspect.isfunction(obj) or inspect.ismethod(obj) or is_cython(obj)
def is_class_method(f):
"""Returns whether the given method is a class_method."""
return hasattr(f, "__self__") and f.__self__ is not None
def is_static_method(cls: type, f_name: str) -> bool:
"""Returns whether the class has a static method with the given name.
Args:
cls: The Python class (i.e. object of type `type`) to
search for the method in.
f_name: The name of the method to look up in this class
and check whether or not it is static.
Returns:
True if ``cls`` defines a static method named ``f_name``.
"""
for base_cls in inspect.getmro(cls):
if f_name in base_cls.__dict__:
return isinstance(base_cls.__dict__[f_name], staticmethod)
return False
+269
View File
@@ -0,0 +1,269 @@
import warnings
from typing import List, Tuple
import ray
import ray._private.profiling as profiling
import ray._private.services as services
import ray._private.worker
from ray._common.network_utils import build_address
from ray._private.state import GlobalState
from ray._raylet import GcsClientOptions
from ray.core.generated import common_pb2
__all__ = ["free", "global_gc"]
MAX_MESSAGE_LENGTH = ray._config.max_grpc_message_size()
def global_gc():
"""Trigger gc.collect() on all workers in the cluster."""
worker = ray._private.worker.global_worker
worker.core_worker.global_gc()
def get_state_from_address(address=None):
address = services.canonicalize_bootstrap_address_or_die(address)
state = GlobalState()
options = GcsClientOptions.create(
address, None, allow_cluster_id_nil=True, fetch_cluster_id_if_nil=False
)
state._initialize_global_state(options)
return state
def memory_summary(
address=None,
group_by="NODE_ADDRESS",
sort_by="OBJECT_SIZE",
units="B",
line_wrap=True,
stats_only=False,
num_entries=None,
):
from ray.dashboard.memory_utils import memory_summary
state = get_state_from_address(address)
reply = get_memory_info_reply(state)
if stats_only:
return store_stats_summary(reply)
return memory_summary(
state, group_by, sort_by, line_wrap, units, num_entries
) + store_stats_summary(reply)
def get_memory_info_reply(
state, node_manager_address=None, node_manager_port=None, timeout_seconds=60.0
):
"""Returns global memory info."""
from ray._private.grpc_utils import init_grpc_channel
from ray.core.generated import node_manager_pb2, node_manager_pb2_grpc
# We can ask any Raylet for the global memory info, that Raylet internally
# asks all nodes in the cluster for memory stats.
if node_manager_address is None or node_manager_port is None:
# We should ask for a raylet that is alive.
raylet = None
for node in state.node_table():
if node["Alive"]:
raylet = node
break
assert raylet is not None, "Every raylet is dead"
raylet_address = build_address(
raylet["NodeManagerAddress"], raylet["NodeManagerPort"]
)
else:
raylet_address = build_address(node_manager_address, node_manager_port)
channel = init_grpc_channel(
raylet_address,
options=[
("grpc.max_send_message_length", MAX_MESSAGE_LENGTH),
("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
],
)
stub = node_manager_pb2_grpc.NodeManagerServiceStub(channel)
reply = stub.FormatGlobalMemoryInfo(
node_manager_pb2.FormatGlobalMemoryInfoRequest(include_memory_info=False),
timeout=timeout_seconds,
)
return reply
def node_stats(
node_manager_address=None, node_manager_port=None, include_memory_info=True
):
"""Returns NodeStats object describing memory usage in the cluster."""
from ray._private.grpc_utils import init_grpc_channel
from ray.core.generated import node_manager_pb2, node_manager_pb2_grpc
# We can ask any Raylet for the global memory info.
assert node_manager_address is not None and node_manager_port is not None
raylet_address = build_address(node_manager_address, node_manager_port)
channel = init_grpc_channel(
raylet_address,
options=[
("grpc.max_send_message_length", MAX_MESSAGE_LENGTH),
("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
],
)
stub = node_manager_pb2_grpc.NodeManagerServiceStub(channel)
node_stats = stub.GetNodeStats(
node_manager_pb2.GetNodeStatsRequest(include_memory_info=include_memory_info),
timeout=30.0,
)
return node_stats
def store_stats_summary(reply):
"""Returns formatted string describing object store stats in all nodes."""
store_summary = "--- Aggregate object store stats across all nodes ---\n"
# TODO(ekl) it would be nice if we could provide a full memory usage
# breakdown by type (e.g., pinned by worker, primary, etc.)
store_summary += (
"Plasma memory usage {} MiB, {} objects, {}% full, {}% "
"needed\n".format(
int(reply.store_stats.object_store_bytes_used / (1024 * 1024)),
reply.store_stats.num_local_objects,
round(
100
* reply.store_stats.object_store_bytes_used
/ reply.store_stats.object_store_bytes_avail,
2,
),
round(
100
* reply.store_stats.object_store_bytes_primary_copy
/ reply.store_stats.object_store_bytes_avail,
2,
),
)
)
if reply.store_stats.object_store_bytes_fallback > 0:
store_summary += "Plasma filesystem mmap usage: {} MiB\n".format(
int(reply.store_stats.object_store_bytes_fallback / (1024 * 1024))
)
if reply.store_stats.spill_time_total_s > 0:
store_summary += (
"Spilled {} MiB, {} objects, avg write throughput {} MiB/s\n".format(
int(reply.store_stats.spilled_bytes_total / (1024 * 1024)),
reply.store_stats.spilled_objects_total,
int(
reply.store_stats.spilled_bytes_total
/ (1024 * 1024)
/ reply.store_stats.spill_time_total_s
),
)
)
if reply.store_stats.restore_time_total_s > 0:
store_summary += (
"Restored {} MiB, {} objects, avg read throughput {} MiB/s\n".format(
int(reply.store_stats.restored_bytes_total / (1024 * 1024)),
reply.store_stats.restored_objects_total,
int(
reply.store_stats.restored_bytes_total
/ (1024 * 1024)
/ reply.store_stats.restore_time_total_s
),
)
)
if reply.store_stats.object_pulls_queued:
store_summary += "Object fetches queued, waiting for available memory."
return store_summary
def free(object_refs: list, local_only: bool = False):
"""
DeprecationWarning: `free` is a deprecated API and will be
removed in a future version of Ray. If you have a use case
for this API, please open an issue on GitHub.
Free a list of IDs from the in-process and plasma object stores.
This function is a low-level API which should be used in restricted
scenarios.
If local_only is false, the request will be send to all object stores.
This method will not return any value to indicate whether the deletion is
successful or not. This function is an instruction to the object store. If
some of the objects are in use, the object stores will delete them later
when the ref count is down to 0.
Examples:
.. testcode::
import ray
@ray.remote
def f():
return 0
obj_ref = f.remote()
ray.get(obj_ref) # wait for object to be created first
free([obj_ref]) # unpin & delete object globally
Args:
object_refs: List of object refs to delete.
local_only: Whether only deleting the list of objects in local
object store or all object stores.
Returns:
None.
"""
warnings.warn(
"`free` is a deprecated API and will be removed in a future version of Ray. "
"If you have a use case for this API, please open an issue on GitHub.",
DeprecationWarning,
)
worker = ray._private.worker.global_worker
if isinstance(object_refs, ray.ObjectRef):
object_refs = [object_refs]
if not isinstance(object_refs, list):
raise TypeError(
"free() expects a list of ObjectRef, got {}".format(type(object_refs))
)
# Make sure that the values are object refs.
for object_ref in object_refs:
if not isinstance(object_ref, ray.ObjectRef):
raise TypeError(
"Attempting to call `free` on the value {}, "
"which is not an ray.ObjectRef.".format(object_ref)
)
worker.check_connected()
with profiling.profile("ray.free"):
if len(object_refs) == 0:
return
worker.core_worker.free_objects(object_refs, local_only)
def get_local_ongoing_lineage_reconstruction_tasks() -> List[
Tuple[common_pb2.LineageReconstructionTask, int]
]:
"""Return the locally submitted ongoing retry tasks
triggered by lineage reconstruction.
NOTE: for the lineage reconstruction task status,
this method only returns the status known to the submitter
(i.e. it returns SUBMITTED_TO_WORKER instead of RUNNING).
The return type is a list of pairs where pair.first is the
lineage reconstruction task info and pair.second is the number
of ongoing lineage reconstruction tasks of this type.
"""
worker = ray._private.worker.global_worker
worker.check_connected()
return worker.core_worker.get_local_ongoing_lineage_reconstruction_tasks()
+230
View File
@@ -0,0 +1,230 @@
import json
import re
from typing import (
Any,
Dict,
List,
Optional,
)
import yaml
import ray._private.ray_constants as ray_constants
# Regex patterns used to validate that labels conform to Kubernetes label syntax rules.
# https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
# Regex for mandatory name (DNS label) or value
# Examples:
# Valid matches: "a", "label-name", "a-._b", "123", "this_is_a_valid_label"
# Invalid matches: "-abc", "abc-", "my@label"
LABEL_REGEX = re.compile(r"([a-zA-Z0-9]([a-zA-Z0-9_.-]{0,61}[a-zA-Z0-9])?)")
# Regex for optional prefix (DNS subdomain)
# Examples:
# Valid matches: "abc", "sub.domain.example", "my-label", "123.456.789"
# Invalid matches: "-abc", "prefix_", "sub..domain", sub.$$.example
LABEL_PREFIX_REGEX = rf"^({LABEL_REGEX.pattern}?(\.{LABEL_REGEX.pattern}?)*)$"
# Supported operators for label selector conditions. Not (!) conditions are handled separately.
LABEL_OPERATORS = {"in"}
# Create a pattern string dynamically based on the LABEL_OPERATORS
OPERATOR_PATTERN = "|".join([re.escape(operator) for operator in LABEL_OPERATORS])
# Regex to match valid label selector operators and values
# Examples:
# Valid matches: "spot", "!GPU", "213521", "in(A123, B456, C789)", "!in(spot, on-demand)", "valid-value"
# Invalid matches: "-spot", "spot_", "in()", "in(spot,", "in(H100, TPU!GPU)", "!!!in(H100, TPU)"
LABEL_SELECTOR_REGEX = re.compile(
rf"^!?(?:{OPERATOR_PATTERN})?\({LABEL_REGEX.pattern}(?:, ?{LABEL_REGEX.pattern})*\)$|^!?{LABEL_REGEX.pattern}$"
)
def parse_node_labels_json(labels_json: str) -> Dict[str, str]:
labels = json.loads(labels_json)
if not isinstance(labels, dict):
raise ValueError("The format after deserialization is not a key-value pair map")
for key, value in labels.items():
if not isinstance(key, str):
raise ValueError("The key is not string type.")
if not isinstance(value, str):
raise ValueError(f'The value of the "{key}" is not string type')
# Validate parsed custom node labels don't begin with ray.io prefix
validate_node_labels(labels)
return labels
def parse_node_labels_string(labels_str: str) -> Dict[str, str]:
labels = {}
# Remove surrounding quotes if they exist
if len(labels_str) > 1 and labels_str.startswith('"') and labels_str.endswith('"'):
labels_str = labels_str[1:-1]
if labels_str == "":
return labels
# Labels argument should consist of a string of key=value pairs
# separated by commas. Labels follow Kubernetes label syntax.
label_pairs = labels_str.split(",")
for pair in label_pairs:
# Split each pair by `=`
key_value = pair.split("=")
if len(key_value) != 2:
raise ValueError("Label string is not a key-value pair.")
key = key_value[0].strip()
value = key_value[1].strip()
labels[key] = value
# Validate parsed node labels follow expected Kubernetes label syntax
validate_node_label_syntax(labels)
return labels
def parse_node_labels_from_yaml_file(path: str) -> Dict[str, str]:
if path == "":
return {}
with open(path, "r") as file:
# Expects valid YAML content
labels = yaml.safe_load(file)
if not isinstance(labels, dict):
raise ValueError(
"The format after deserialization is not a key-value pair map."
)
for key, value in labels.items():
if not isinstance(key, str):
raise ValueError("The key is not string type.")
if not isinstance(value, str):
raise ValueError(f'The value of "{key}" is not string type.')
# Validate parsed node labels follow expected Kubernetes label syntax
validate_node_label_syntax(labels)
return labels
# TODO (ryanaoleary@): This function will be removed after the migration to the label
# selector API from NodeLabelSchedulingPolicy is complete.
def validate_node_labels(labels: Dict[str, str]):
if labels is None:
return
for key in labels.keys():
if key.startswith(ray_constants.RAY_DEFAULT_LABEL_KEYS_PREFIX):
raise ValueError(
f"Custom label keys `{key}` cannot start with the prefix "
f"`{ray_constants.RAY_DEFAULT_LABEL_KEYS_PREFIX}`. "
f"This is reserved for Ray defined labels."
)
def validate_label_key(key: str) -> Optional[str]:
if "/" in key:
prefix, name = key.rsplit("/", 1)
if len(prefix) > 253 or not re.fullmatch(LABEL_PREFIX_REGEX, prefix):
return str(
f"Invalid label key prefix `{prefix}`. Prefix must be a series of DNS labels "
f"separated by dots (.), not longer than 253 characters in total."
)
else:
name = key
if len(name) > 63 or not re.fullmatch(LABEL_REGEX, name):
return str(
f"Invalid label key name `{name}`. Name must be 63 chars or less beginning and ending "
f"with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), underscores (_),"
f"dots (.), and alphanumerics between."
)
return None
def validate_label_value(value: str):
if value == "":
return
if len(value) > 63 or not re.fullmatch(LABEL_REGEX, value):
raise ValueError(
f"Invalid label key value `{value}`. Value must be 63 chars or less beginning and ending "
f"with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), underscores (_),"
f"dots (.), and alphanumerics between."
)
def validate_label_selector(label_selector: Optional[Dict[str, str]]) -> Optional[str]:
if label_selector is None:
return None
for key, value in label_selector.items():
possible_error_message = validate_label_key(key)
if possible_error_message:
return possible_error_message
if value is not None:
possible_error_message = validate_label_selector_value(value)
if possible_error_message:
return possible_error_message
return None
def validate_label_selector_value(selector: str) -> Optional[str]:
if selector == "":
return None
if not re.fullmatch(LABEL_SELECTOR_REGEX, selector):
return str(
f"Invalid label selector value `{selector}`. The label selector value should contain optional operators and a label value. Supported operators are: ! and {LABEL_OPERATORS}. "
f"Value must be 63 chars or less beginning and ending "
f"with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), underscores (_),"
f"dots (.), and alphanumerics between."
)
return None
# TODO (ryanaoleary@): This function will replace `validate_node_labels` after
# the migration from NodeLabelSchedulingPolicy to the Label Selector API is complete.
def validate_node_label_syntax(labels: Dict[str, str]):
if labels is None:
return
for key, value in labels.items():
possible_error_message = validate_label_key(key)
if possible_error_message:
raise ValueError(possible_error_message)
if value is not None:
validate_label_value(value)
def validate_fallback_strategy(
fallback_strategy: Optional[List[Dict[str, Any]]]
) -> Optional[str]:
if fallback_strategy is None:
return None
# Supported options for `fallback_strategy` scheduling.
supported_options = {"label_selector"}
for strategy in fallback_strategy:
if not isinstance(strategy, dict):
return "Each element in fallback_strategy must be a dictionary."
if not strategy:
return "Empty dictionary found in `fallback_strategy`."
# Validate `fallback_strategy` only contains supported options.
for option in strategy:
if option not in supported_options:
return (
f"Unsupported option found: '{option}'. "
f"Only {list(supported_options)} is currently supported."
)
# Validate the 'label_selector' dictionary.
label_selector = strategy.get("label_selector")
if label_selector:
if not isinstance(label_selector, dict):
return 'The value of "label_selector" must be a dictionary.'
error_message = validate_label_selector(label_selector)
if error_message:
return error_message
return None
+150
View File
@@ -0,0 +1,150 @@
import logging
import threading
import time
from typing import Optional, Union
INTERNAL_TIMESTAMP_LOG_KEY = "_ray_timestamp_ns"
def _print_loggers():
"""Print a formatted list of loggers and their handlers for debugging."""
loggers = {logging.root.name: logging.root}
loggers.update(dict(sorted(logging.root.manager.loggerDict.items())))
for name, logger in loggers.items():
if isinstance(logger, logging.Logger):
print(f" {name}: disabled={logger.disabled}, propagate={logger.propagate}")
for handler in logger.handlers:
print(f" {handler}")
def clear_logger(logger: Union[str, logging.Logger]):
"""Reset a logger, clearing its handlers and enabling propagation.
Args:
logger: Logger to be cleared
"""
if isinstance(logger, str):
logger = logging.getLogger(logger)
logger.propagate = True
logger.handlers.clear()
class PlainRayHandler(logging.StreamHandler):
"""A plain log handler.
This handler writes to whatever sys.stderr points to at emit-time,
not at instantiation time. See docs for logging._StderrHandler.
"""
def __init__(self):
super().__init__()
self.plain_handler = logging._StderrHandler()
self.plain_handler.level = self.level
self.plain_handler.formatter = logging.Formatter(fmt="%(message)s")
def emit(self, record: logging.LogRecord):
"""Emit the log message.
If this is a worker, bypass fancy logging and just emit the log record.
If this is the driver, emit the message using the appropriate console handler.
Args:
record: Log record to be emitted
"""
import ray
if (
hasattr(ray, "_private")
and hasattr(ray._private, "worker")
and ray._private.worker.global_worker.mode
== ray._private.worker.WORKER_MODE
):
self.plain_handler.emit(record)
else:
logging._StderrHandler.emit(self, record)
logger_initialized = False
logging_config_lock = threading.Lock()
def _setup_log_record_factory():
"""Setup log record factory to add _ray_timestamp_ns to LogRecord."""
old_factory = logging.getLogRecordFactory()
def record_factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
# Python logging module starts to use `time.time_ns()` to generate `created`
# from Python 3.13 to avoid the precision loss caused by the float type.
# Here, we generate the `created` for the LogRecord to support older Python
# versions.
ct = time.time_ns()
record.created = ct / 1e9
record.__dict__[INTERNAL_TIMESTAMP_LOG_KEY] = ct
return record
logging.setLogRecordFactory(record_factory)
def generate_logging_config():
"""Generate the default Ray logging configuration."""
with logging_config_lock:
global logger_initialized
if logger_initialized:
return
logger_initialized = True
plain_formatter = logging.Formatter(
"%(asctime)s\t%(levelname)s %(filename)s:%(lineno)s -- %(message)s"
)
default_handler = PlainRayHandler()
default_handler.setFormatter(plain_formatter)
ray_logger = logging.getLogger("ray")
ray_logger.setLevel(logging.INFO)
ray_logger.addHandler(default_handler)
ray_logger.propagate = False
# Special handling for ray.rllib: only warning-level messages passed through
# See https://github.com/ray-project/ray/pull/31858 for related PR
rllib_logger = logging.getLogger("ray.rllib")
rllib_logger.setLevel(logging.WARN)
# Set up the LogRecord factory.
_setup_log_record_factory()
def setup_process_exit_logger(
process_exit_log_path: str,
level: int = logging.INFO,
formatter: Optional[logging.Formatter] = None,
) -> logging.Logger:
"""Configure and return the 'ray.process_exit' logger with a FileHandler."""
logger = logging.getLogger("ray.process_exit")
logger.setLevel(level)
logger.propagate = False
fh = logging.FileHandler(process_exit_log_path, encoding="utf-8")
if formatter is None:
formatter = logging.Formatter(
"%(asctime)s\t%(levelname)s %(filename)s:%(lineno)s -- %(message)s"
)
fh.setFormatter(formatter)
logger.addHandler(fh)
return logger
def format_returncode(rc: Optional[int]) -> str:
"""Return a consistent string for process return code."""
if rc is None:
return "None"
try:
rc_int = int(rc)
except Exception:
return str(rc)
if rc_int < 0:
return f"{rc_int} (signal {-rc_int})"
return f"{rc_int}"
+642
View File
@@ -0,0 +1,642 @@
import argparse
import errno
import glob
import logging
import logging.handlers
import os
import platform
import re
import shutil
import sys
import time
import traceback
from typing import Callable, List, Optional, Set
import ray._private.ray_constants as ray_constants
import ray._private.utils
from ray._private import logging_utils
from ray._private.ray_logging import setup_component_logger
from ray._raylet import GcsClient
# Logger for this module. It should be configured at the entry point
# into the program using Ray. Ray provides a default configuration at
# entry/init points.
logger = logging.getLogger(__name__)
# The groups are job id, and pid.
WORKER_LOG_PATTERN = re.compile(r".*worker.*-([0-9a-f]+)-(\d+)")
# The groups are job id.
RUNTIME_ENV_SETUP_PATTERN = re.compile(r".*runtime_env_setup-(\d+).log")
# Log name update interval under pressure.
# We need it because log name update is CPU intensive and uses 100%
# of cpu when there are many log files.
LOG_NAME_UPDATE_INTERVAL_S = float(os.getenv("LOG_NAME_UPDATE_INTERVAL_S", 0.5))
# Once there are more files than this threshold,
# log monitor start giving backpressure to lower cpu usages.
RAY_LOG_MONITOR_MANY_FILES_THRESHOLD = int(
os.getenv("RAY_LOG_MONITOR_MANY_FILES_THRESHOLD", 1000)
)
RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED = int(
os.getenv("RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED", 0)
)
class LogFileInfo:
def __init__(
self,
filename=None,
size_when_last_opened=None,
file_position=None,
file_handle=None,
is_err_file=False,
job_id=None,
worker_pid=None,
):
assert (
filename is not None
and size_when_last_opened is not None
and file_position is not None
)
self.filename = filename
self.size_when_last_opened = size_when_last_opened
self.file_position = file_position
self.file_handle = file_handle
self.is_err_file = is_err_file
self.job_id = job_id
self.worker_pid = worker_pid
self.actor_name = None
self.task_name = None
def reopen_if_necessary(self):
"""Check if the file's inode has changed and reopen it if necessary.
There are a variety of reasons what we would logically consider a file
would have different inodes, such as log rotation or file syncing
semantics.
If the file is smaller than our recorded file position, we assume it has been
rotated and start reading it from the beginning.
"""
try:
open_inode = None
if self.file_handle and not self.file_handle.closed:
open_inode = os.fstat(self.file_handle.fileno()).st_ino
new_statinfo = os.stat(self.filename)
if new_statinfo.st_ino != open_inode:
self.file_handle = open(self.filename, "rb")
# If the new file is smaller than the last read position, assume that
# the file has been rotated and read from the beginning. Else, continue
# from the existing file position.
if new_statinfo.st_size < self.file_position:
self.file_position = 0
self.file_handle.seek(self.file_position)
self.size_when_last_opened = new_statinfo.st_size
else:
# Inode unchanged, but the file may have been truncated and
# rewritten in place. Compare against both the last read
# position and the last observed file size so we can detect
# rewrites even when the new content grows beyond the old
# position before the next poll.
if new_statinfo.st_size < max(
self.file_position, self.size_when_last_opened
):
reopened_file = open(self.filename, "rb")
self.file_handle.close()
self.file_handle = reopened_file
self.file_position = 0
self.size_when_last_opened = new_statinfo.st_size
except Exception:
logger.debug(f"file no longer exists, skip re-opening of {self.filename}")
def __repr__(self):
return (
"FileInfo(\n"
f"\tfilename: {self.filename}\n"
f"\tsize_when_last_opened: {self.size_when_last_opened}\n"
f"\tfile_position: {self.file_position}\n"
f"\tfile_handle: {self.file_handle}\n"
f"\tis_err_file: {self.is_err_file}\n"
f"\tjob_id: {self.job_id}\n"
f"\tworker_pid: {self.worker_pid}\n"
f"\tactor_name: {self.actor_name}\n"
f"\ttask_name: {self.task_name}\n"
")"
)
class LogMonitor:
"""A monitor process for monitoring Ray log files.
This class maintains a list of open files and a list of closed log files. We
can't simply leave all files open because we'll run out of file
descriptors.
The "run" method of this class will cycle between doing several things:
1. First, it will check if any new files have appeared in the log
directory. If so, they will be added to the list of closed files.
2. Then, if we are unable to open any new files, we will close all of the
files.
3. Then, we will open as many closed files as we can that may have new
lines (judged by an increase in file size since the last time the file
was opened).
4. Then we will loop through the open files and see if there are any new
lines in the file. If so, we will publish them to Ray pubsub.
Attributes:
ip: The hostname of this machine, for grouping log messages.
logs_dir: The directory that the log files are in.
log_filenames: This is the set of filenames of all files in
open_file_infos and closed_file_infos.
open_file_infos (list[LogFileInfo]): Info for all of the open files.
closed_file_infos (list[LogFileInfo]): Info for all of the closed
files.
can_open_more_files: True if we can still open more files and
false otherwise.
max_files_open: The maximum number of files that can be open.
"""
def __init__(
self,
node_ip_address: str,
logs_dir: str,
gcs_client: GcsClient,
is_proc_alive_fn: Callable[[int], bool],
max_files_open: int = ray_constants.LOG_MONITOR_MAX_OPEN_FILES,
gcs_address: Optional[str] = None,
):
"""Initialize the log monitor object."""
self.ip: str = node_ip_address
self.logs_dir: str = logs_dir
self.gcs_client = gcs_client
self.log_filenames: Set[str] = set()
self.open_file_infos: List[LogFileInfo] = []
self.closed_file_infos: List[LogFileInfo] = []
self.can_open_more_files: bool = True
self.max_files_open: int = max_files_open
self.is_proc_alive_fn: Callable[[int], bool] = is_proc_alive_fn
self.is_autoscaler_v2: bool = self.get_is_autoscaler_v2(gcs_address)
logger.info(
f"Starting log monitor with [max open files={max_files_open}],"
f" [is_autoscaler_v2={self.is_autoscaler_v2}]"
)
def get_is_autoscaler_v2(self, gcs_address: Optional[str]) -> bool:
"""Check if autoscaler v2 is enabled."""
if gcs_address is None:
return False
if not ray.experimental.internal_kv._internal_kv_initialized():
ray.experimental.internal_kv._initialize_internal_kv(self.gcs_client)
from ray.autoscaler.v2.utils import is_autoscaler_v2
return is_autoscaler_v2()
def _close_all_files(self):
"""Close all open files (so that we can open more)."""
while len(self.open_file_infos) > 0:
file_info = self.open_file_infos.pop(0)
file_info.file_handle.close()
file_info.file_handle = None
proc_alive = True
# Test if the worker process that generated the log file
# is still alive. Only applies to worker processes.
# For all other system components, we always assume they are alive.
if (
file_info.worker_pid != "raylet"
and file_info.worker_pid != "gcs_server"
and file_info.worker_pid != "autoscaler"
and file_info.worker_pid != "runtime_env"
and file_info.worker_pid is not None
):
assert not isinstance(file_info.worker_pid, str), (
"PID should be an int type. " f"Given PID: {file_info.worker_pid}."
)
proc_alive = self.is_proc_alive_fn(file_info.worker_pid)
if not proc_alive:
# The process is not alive any more, so move the log file
# out of the log directory so glob.glob will not be slowed
# by it.
target = os.path.join(
self.logs_dir, "old", os.path.basename(file_info.filename)
)
try:
shutil.move(file_info.filename, target)
except (IOError, OSError) as e:
if e.errno == errno.ENOENT:
logger.warning(
f"Warning: The file {file_info.filename} was not found."
)
else:
raise e
if proc_alive:
self.closed_file_infos.append(file_info)
self.can_open_more_files = True
def update_log_filenames(self):
"""Update the list of log files to monitor."""
monitor_log_paths = []
# output of user code is written here
monitor_log_paths += glob.glob(
f"{self.logs_dir}/worker*[.out|.err]"
) + glob.glob(f"{self.logs_dir}/java-worker*.log")
# segfaults and other serious errors are logged here
monitor_log_paths += glob.glob(f"{self.logs_dir}/raylet*.err")
# monitor logs are needed to report autoscaler events
# TODO(rickyx): remove this after migration.
if not self.is_autoscaler_v2:
# We publish monitor logs in autoscaler v1
monitor_log_paths += glob.glob(f"{self.logs_dir}/monitor.log")
else:
# We publish autoscaler events directly in autoscaler v2
monitor_log_paths += glob.glob(
f"{self.logs_dir}/events/event_AUTOSCALER.log"
)
# If gcs server restarts, there can be multiple log files.
monitor_log_paths += glob.glob(f"{self.logs_dir}/gcs_server*.err")
# Add libtpu logs if they exist in the Ray container.
tpu_log_dir = f"{self.logs_dir}/tpu_logs"
if os.path.isdir(tpu_log_dir):
monitor_log_paths += glob.glob(f"{self.logs_dir}/tpu_logs/**")
# runtime_env setup process is logged here
if RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED:
monitor_log_paths += glob.glob(f"{self.logs_dir}/runtime_env*.log")
for file_path in monitor_log_paths:
if os.path.isfile(file_path) and file_path not in self.log_filenames:
worker_match = WORKER_LOG_PATTERN.match(file_path)
if worker_match:
worker_pid = int(worker_match.group(2))
else:
worker_pid = None
job_id = None
# Perform existence check first because most file will not be
# including runtime_env. This saves some cpu cycle.
if "runtime_env" in file_path:
runtime_env_job_match = RUNTIME_ENV_SETUP_PATTERN.match(file_path)
if runtime_env_job_match:
job_id = runtime_env_job_match.group(1)
is_err_file = file_path.endswith("err")
self.log_filenames.add(file_path)
self.closed_file_infos.append(
LogFileInfo(
filename=file_path,
size_when_last_opened=0,
file_position=0,
file_handle=None,
is_err_file=is_err_file,
job_id=job_id,
worker_pid=worker_pid,
)
)
log_filename = os.path.basename(file_path)
logger.info(f"Beginning to track file {log_filename}")
def open_closed_files(self):
"""Open some closed files if they may have new lines.
Opening more files may require us to close some of the already open
files.
"""
if not self.can_open_more_files:
# If we can't open any more files. Close all of the files.
self._close_all_files()
files_with_no_updates = []
while len(self.closed_file_infos) > 0:
if len(self.open_file_infos) >= self.max_files_open:
self.can_open_more_files = False
break
file_info = self.closed_file_infos.pop(0)
assert file_info.file_handle is None
# Get the file size to see if it has gotten bigger since we last
# opened it.
try:
file_size = os.path.getsize(file_info.filename)
except (IOError, OSError) as e:
# Catch "file not found" errors.
if e.errno == errno.ENOENT:
logger.warning(
f"Warning: The file {file_info.filename} was not found."
)
self.log_filenames.remove(file_info.filename)
continue
raise e
# If some new lines have been added to this file, try to reopen the
# file.
if file_size > file_info.size_when_last_opened:
try:
f = open(file_info.filename, "rb")
except (IOError, OSError) as e:
if e.errno == errno.ENOENT:
logger.warning(
f"Warning: The file {file_info.filename} was not found."
)
self.log_filenames.remove(file_info.filename)
continue
else:
raise e
f.seek(file_info.file_position)
file_info.size_when_last_opened = file_size
file_info.file_handle = f
self.open_file_infos.append(file_info)
else:
files_with_no_updates.append(file_info)
if len(self.open_file_infos) >= self.max_files_open:
self.can_open_more_files = False
# Add the files with no changes back to the list of closed files.
self.closed_file_infos += files_with_no_updates
def check_log_files_and_publish_updates(self):
"""Gets updates to the log files and publishes them.
Returns:
True if anything was published and false otherwise.
"""
anything_published = False
lines_to_publish = []
def flush():
nonlocal lines_to_publish
nonlocal anything_published
if len(lines_to_publish) > 0:
data = {
"ip": self.ip,
"pid": file_info.worker_pid,
"job": file_info.job_id,
"is_err": file_info.is_err_file,
"lines": lines_to_publish,
"actor_name": file_info.actor_name,
"task_name": file_info.task_name,
}
try:
self.gcs_client.publish_logs(data)
except Exception:
logger.exception(f"Failed to publish log messages {data}")
anything_published = True
lines_to_publish = []
for file_info in self.open_file_infos:
assert not file_info.file_handle.closed
file_info.reopen_if_necessary()
max_num_lines_to_read = ray_constants.LOG_MONITOR_NUM_LINES_TO_READ
for _ in range(max_num_lines_to_read):
try:
next_line = file_info.file_handle.readline()
# Replace any characters not in UTF-8 with
# a replacement character, see
# https://stackoverflow.com/a/38565489/10891801
next_line = next_line.decode("utf-8", "replace")
if next_line == "":
break
next_line = next_line.rstrip("\r\n")
if next_line.startswith(ray_constants.LOG_PREFIX_ACTOR_NAME):
flush() # Possible change of task/actor name.
file_info.actor_name = next_line.split(
ray_constants.LOG_PREFIX_ACTOR_NAME, 1
)[1]
file_info.task_name = None
elif next_line.startswith(ray_constants.LOG_PREFIX_TASK_NAME):
flush() # Possible change of task/actor name.
file_info.task_name = next_line.split(
ray_constants.LOG_PREFIX_TASK_NAME, 1
)[1]
elif next_line.startswith(ray_constants.LOG_PREFIX_JOB_ID):
file_info.job_id = next_line.split(
ray_constants.LOG_PREFIX_JOB_ID, 1
)[1]
elif next_line.startswith(
"Windows fatal exception: access violation"
):
# We are suppressing the
# 'Windows fatal exception: access violation'
# message on workers on Windows here.
# As far as we know it is harmless,
# but is frequently popping up if Python
# functions are run inside the core
# worker C extension. See the investigation in
# github.com/ray-project/ray/issues/18944
# Also skip the following line, which is an
# empty line.
file_info.file_handle.readline()
else:
lines_to_publish.append(next_line)
except Exception:
logger.error(
f"Error: Reading file: {file_info.filename}, "
f"position: {file_info.file_info.file_handle.tell()} "
"failed."
)
raise
if file_info.file_position == 0:
# make filename windows-agnostic
filename = file_info.filename.replace("\\", "/")
if "/raylet" in filename:
file_info.worker_pid = "raylet"
elif "/gcs_server" in filename:
file_info.worker_pid = "gcs_server"
elif "/monitor" in filename or "event_AUTOSCALER" in filename:
file_info.worker_pid = "autoscaler"
elif "/runtime_env" in filename:
file_info.worker_pid = "runtime_env"
# Record the current position in the file.
file_info.file_position = file_info.file_handle.tell()
flush()
return anything_published
def should_update_filenames(self, last_file_updated_time: float) -> bool:
"""Return true if filenames should be updated.
This method is used to apply the backpressure on file updates because
that requires heavy glob operations which use lots of CPUs.
Args:
last_file_updated_time: The last time filenames are updated.
Returns:
True if filenames should be updated. False otherwise.
"""
elapsed_seconds = float(time.time() - last_file_updated_time)
return (
len(self.log_filenames) < RAY_LOG_MONITOR_MANY_FILES_THRESHOLD
or elapsed_seconds > LOG_NAME_UPDATE_INTERVAL_S
)
def run(self):
"""Run the log monitor.
This will scan the file system once every LOG_NAME_UPDATE_INTERVAL_S to
check if there are new log files to monitor. It will also publish new
log lines.
"""
last_updated = time.time()
while True:
if self.should_update_filenames(last_updated):
self.update_log_filenames()
last_updated = time.time()
self.open_closed_files()
anything_published = self.check_log_files_and_publish_updates()
# If nothing was published, then wait a little bit before checking
# for logs to avoid using too much CPU.
if not anything_published:
time.sleep(0.1)
def is_proc_alive(pid):
# Import locally to make sure the bundled version is used if needed
import psutil
try:
return psutil.Process(pid).is_running()
except psutil.NoSuchProcess:
# The process does not exist.
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=("Parse GCS server address for the log monitor to connect to.")
)
parser.add_argument(
"--gcs-address", required=False, type=str, help="The address (ip:port) of GCS."
)
parser.add_argument(
"--node-ip-address",
required=False,
type=str,
help="The IP address of the node.",
)
parser.add_argument(
"--logging-level",
required=False,
type=str,
default=ray_constants.LOGGER_LEVEL,
choices=ray_constants.LOGGER_LEVEL_CHOICES,
help=ray_constants.LOGGER_LEVEL_HELP,
)
parser.add_argument(
"--logging-format",
required=False,
type=str,
default=ray_constants.LOGGER_FORMAT,
help=ray_constants.LOGGER_FORMAT_HELP,
)
parser.add_argument(
"--logging-filename",
required=False,
type=str,
default=ray_constants.LOG_MONITOR_LOG_FILE_NAME,
help="Specify the name of log file, "
"log to stderr if set empty, default is "
f'"{ray_constants.LOG_MONITOR_LOG_FILE_NAME}"',
)
parser.add_argument(
"--session-dir",
required=True,
type=str,
help="Specify the path of the session directory used by Ray processes.",
)
parser.add_argument(
"--logs-dir",
required=True,
type=str,
help="Specify the path of the log directory used by Ray processes.",
)
parser.add_argument(
"--logging-rotate-bytes",
required=True,
type=int,
help="Specify the max bytes for rotating log file.",
)
parser.add_argument(
"--logging-rotate-backup-count",
required=True,
type=int,
help="Specify the backup count of rotated log file.",
)
parser.add_argument(
"--stdout-filepath",
required=False,
default="",
type=str,
help="The filepath to dump log monitor stdout.",
)
parser.add_argument(
"--stderr-filepath",
required=False,
default="",
type=str,
help="The filepath to dump log monitor stderr.",
)
args = parser.parse_args()
# Disable log rotation for windows platform.
logging_rotation_bytes = args.logging_rotate_bytes if sys.platform != "win32" else 0
logging_rotation_backup_count = (
args.logging_rotate_backup_count if sys.platform != "win32" else 1
)
logging_params = dict(
logging_level=args.logging_level,
logging_format=args.logging_format,
log_dir=args.logs_dir,
filename=args.logging_filename,
max_bytes=logging_rotation_bytes,
backup_count=logging_rotation_backup_count,
)
logger = setup_component_logger(**logging_params)
# Setup stdout/stderr redirect files if redirection enabled
logging_utils.redirect_stdout_stderr_if_needed(
args.stdout_filepath,
args.stderr_filepath,
logging_rotation_bytes,
logging_rotation_backup_count,
)
gcs_client = GcsClient(address=args.gcs_address)
log_monitor = LogMonitor(
args.node_ip_address,
args.logs_dir,
gcs_client,
is_proc_alive,
gcs_address=args.gcs_address,
)
try:
log_monitor.run()
except Exception as e:
# Something went wrong, so push an error to all drivers.
traceback_str = ray._private.utils.format_error_message(traceback.format_exc())
message = (
f"The log monitor on node {platform.node()} "
f"failed with the following error:\n{traceback_str}"
)
ray._private.utils.publish_error_to_driver(
ray_constants.LOG_MONITOR_DIED_ERROR,
message,
gcs_client=gcs_client,
)
logger.error(message)
raise e
+49
View File
@@ -0,0 +1,49 @@
import sys
from ray._private.utils import open_log
from ray._raylet import StreamRedirector
def redirect_stdout_stderr_if_needed(
stdout_filepath: str,
stderr_filepath: str,
rotation_bytes: int,
rotation_backup_count: int,
):
"""This function sets up redirection for stdout and stderr if needed, based on the given rotation parameters.
params:
stdout_filepath: the filepath stdout will be redirected to; if empty, stdout will not be redirected.
stderr_filepath: the filepath stderr will be redirected to; if empty, stderr will not be redirected.
rotation_bytes: number of bytes which triggers file rotation.
rotation_backup_count: the max size of rotation files.
"""
# Setup redirection for stdout and stderr.
if stdout_filepath:
StreamRedirector.redirect_stdout(
stdout_filepath,
rotation_bytes,
rotation_backup_count,
False, # tee_to_stdout
False, # tee_to_stderr
)
if stderr_filepath:
StreamRedirector.redirect_stderr(
stderr_filepath,
rotation_bytes,
rotation_backup_count,
False, # tee_to_stdout
False, # tee_to_stderr
)
# Setup python system stdout/stderr.
stdout_fileno = sys.stdout.fileno()
stderr_fileno = sys.stderr.fileno()
# We also manually set sys.stdout and sys.stderr because that seems to
# have an effect on the output buffering. Without doing this, stdout
# and stderr are heavily buffered resulting in seemingly lost logging
# statements. We never want to close the stdout file descriptor, dup2 will
# close it when necessary and we don't want python's GC to close it.
sys.stdout = open_log(stdout_fileno, unbuffered=True, closefd=False)
sys.stderr = open_log(stderr_fileno, unbuffered=True, closefd=False)
+165
View File
@@ -0,0 +1,165 @@
import logging
import os
import platform
import sys
import time
import ray # noqa F401
# Import ray before psutil will make sure we use psutil's bundled version
from ray._common.utils import get_system_memory
import psutil # noqa E402
logger = logging.getLogger(__name__)
def get_rss(memory_info):
"""Get the estimated non-shared memory usage from psutil memory_info."""
mem = memory_info.rss
# OSX doesn't have the shared attribute
if hasattr(memory_info, "shared"):
mem -= memory_info.shared
return mem
def get_shared(virtual_memory):
"""Get the estimated shared memory usage from psutil virtual mem info."""
# OSX doesn't have the shared attribute
if hasattr(virtual_memory, "shared"):
return virtual_memory.shared
else:
return 0
def get_top_n_memory_usage(n: int = 10):
"""Get the top n memory usage of the process
Params:
n: Number of top n process memory usage to return.
Returns:
(str) The formatted string of top n process memory usage.
"""
proc_stats = []
for proc in psutil.process_iter(["memory_info", "cmdline"]):
try:
proc_stats.append(
(get_rss(proc.info["memory_info"]), proc.pid, proc.info["cmdline"])
)
except psutil.NoSuchProcess:
# We should skip the process that has exited. Refer this
# issue for more detail:
# https://github.com/ray-project/ray/issues/14929
continue
except psutil.AccessDenied:
# On MacOS, the proc_pidinfo call (used to get per-process
# memory info) fails with a permission denied error when used
# on a process that isnt owned by the same user. For now, we
# drop the memory info of any such process, assuming that
# processes owned by other users (e.g. root) aren't Ray
# processes and will be of less interest when an OOM happens
# on a Ray node.
# See issue for more detail:
# https://github.com/ray-project/ray/issues/11845#issuecomment-849904019 # noqa: E501
continue
proc_str = "PID\tMEM\tCOMMAND"
for rss, pid, cmdline in sorted(proc_stats, reverse=True)[:n]:
proc_str += "\n{}\t{}GiB\t{}".format(
pid, round(rss / (1024**3), 2), " ".join(cmdline)[:100].strip()
)
return proc_str
class RayOutOfMemoryError(Exception):
def __init__(self, msg):
Exception.__init__(self, msg)
@staticmethod
def get_message(used_gb, total_gb, threshold):
proc_str = get_top_n_memory_usage(n=10)
return (
"More than {}% of the memory on ".format(int(100 * threshold))
+ "node {} is used ({} / {} GB). ".format(
platform.node(), round(used_gb, 2), round(total_gb, 2)
)
+ f"The top 10 memory consumers are:\n\n{proc_str}"
+ "\n\nIn addition, up to {} GiB of shared memory is ".format(
round(get_shared(psutil.virtual_memory()) / (1024**3), 2)
)
+ "currently being used by the Ray object store.\n---\n"
"--- Tip: Use the `ray memory` command to list active "
"objects in the cluster.\n"
"--- To disable OOM exceptions, set "
"RAY_DISABLE_MEMORY_MONITOR=1.\n---\n"
)
class MemoryMonitor:
"""Helper class for raising errors on low memory.
This presents a much cleaner error message to users than what would happen
if we actually ran out of memory.
The monitor tries to use the cgroup memory limit and usage if it is set
and available so that it is more reasonable inside containers. Otherwise,
it uses `psutil` to check the memory usage.
The environment variable `RAY_MEMORY_MONITOR_ERROR_THRESHOLD` can be used
to overwrite the default error_threshold setting.
Used by test only. For production code use memory_monitor_interface.cc
"""
def __init__(self, error_threshold=0.95, check_interval=1):
# Note: it takes ~50us to check the memory usage through psutil, so
# throttle this check at most once a second or so.
self.check_interval = check_interval
self.last_checked = 0
try:
self.error_threshold = float(
os.getenv("RAY_MEMORY_MONITOR_ERROR_THRESHOLD")
)
except (ValueError, TypeError):
self.error_threshold = error_threshold
# Try to read the cgroup memory limit if it is available.
try:
with open("/sys/fs/cgroup/memory/memory.limit_in_bytes", "rb") as f:
self.cgroup_memory_limit_gb = int(f.read()) / (1024**3)
except IOError:
self.cgroup_memory_limit_gb = sys.maxsize / (1024**3)
if not psutil:
logger.warning(
"WARNING: Not monitoring node memory since `psutil` "
"is not installed. Install this with "
"`pip install psutil` to enable "
"debugging of memory-related crashes."
)
self.disabled = (
"RAY_DEBUG_DISABLE_MEMORY_MONITOR" in os.environ
or "RAY_DISABLE_MEMORY_MONITOR" in os.environ
)
def get_memory_usage(self):
from ray._private.utils import get_used_memory
total_gb = get_system_memory() / (1024**3)
used_gb = get_used_memory() / (1024**3)
return used_gb, total_gb
def raise_if_low_memory(self):
if self.disabled:
return
if time.time() - self.last_checked > self.check_interval:
self.last_checked = time.time()
used_gb, total_gb = self.get_memory_usage()
if used_gb > total_gb * self.error_threshold:
raise RayOutOfMemoryError(
RayOutOfMemoryError.get_message(
used_gb, total_gb, self.error_threshold
)
)
else:
logger.debug(f"Memory usage is {used_gb} / {total_gb}")
+911
View File
@@ -0,0 +1,911 @@
import json
import logging
import os
import re
import threading
import time
import traceback
from collections import defaultdict, namedtuple
from typing import Any, Dict, List, Set, Tuple, Union
from opencensus.metrics.export.metric_descriptor import MetricDescriptorType
from opencensus.metrics.export.value import ValueDouble
from opencensus.stats import aggregation, measure as measure_module
from opencensus.stats.aggregation_data import (
CountAggregationData,
DistributionAggregationData,
LastValueAggregationData,
SumAggregationData,
)
from opencensus.stats.base_exporter import StatsExporter
from opencensus.stats.stats_recorder import StatsRecorder
from opencensus.stats.view import View
from opencensus.stats.view_manager import ViewManager
from opencensus.tags import (
tag_key as tag_key_module,
tag_map as tag_map_module,
tag_value as tag_value_module,
)
from prometheus_client.core import (
CounterMetricFamily,
GaugeMetricFamily,
HistogramMetricFamily,
Metric as PrometheusMetric,
)
import ray
from ray._common.network_utils import build_address
from ray._private.ray_constants import env_bool
from ray._private.telemetry.metric_cardinality import (
WORKER_ID_TAG_KEY,
MetricCardinality,
)
from ray._raylet import GcsClient
from ray.core.generated.metrics_pb2 import Metric
from ray.util.metrics import _is_invalid_metric_name
logger = logging.getLogger(__name__)
# Env var key to decide worker timeout.
# If the worker doesn't report for more than
# this time, we treat workers as dead.
RAY_WORKER_TIMEOUT_S = "RAY_WORKER_TIMEOUT_S"
GLOBAL_COMPONENT_KEY = "CORE"
RE_NON_ALPHANUMS = re.compile(r"[^a-zA-Z0-9]")
class Gauge(View):
"""Gauge representation of opencensus view.
This class is used to collect process metrics from the reporter agent.
Cpp metrics should be collected in a different way.
"""
def __init__(self, name, description, unit, tags: List[str]):
if _is_invalid_metric_name(name):
raise ValueError(
f"Invalid metric name: {name}. Metric will be discarded "
"and data will not be collected or published. "
"Metric names can only contain letters, numbers, _, and :. "
"Metric names cannot start with numbers."
)
self._measure = measure_module.MeasureInt(name, description, unit)
self._description = description
tags = [tag_key_module.TagKey(tag) for tag in tags]
self._view = View(
name, description, tags, self.measure, aggregation.LastValueAggregation()
)
@property
def measure(self):
return self._measure
@property
def view(self):
return self._view
@property
def name(self):
return self.measure.name
@property
def description(self):
return self._description
Record = namedtuple("Record", ["gauge", "value", "tags"])
def fix_grpc_metric(metric: Metric):
"""
Fix the inbound `opencensus.proto.metrics.v1.Metric` protos to make it acceptable
by opencensus.stats.DistributionAggregationData.
- metric name: gRPC OpenCensus metrics have names with slashes and dots, e.g.
`grpc.io/client/server_latency`[1]. However Prometheus metric names only take
alphanums,underscores and colons[2]. We santinize the name by replacing non-alphanum
chars to underscore, like the official opencensus prometheus exporter[3].
- distribution bucket bounds: The Metric proto asks distribution bucket bounds to
be > 0 [4]. However, gRPC OpenCensus metrics have their first bucket bound == 0 [1].
This makes the `DistributionAggregationData` constructor to raise Exceptions. This
applies to all bytes and milliseconds (latencies). The fix: we update the initial 0
bounds to be 0.000_000_1. This will not affect the precision of the metrics, since
we don't expect any less-than-1 bytes, or less-than-1-nanosecond times.
[1] https://github.com/census-instrumentation/opencensus-specs/blob/master/stats/gRPC.md#units # noqa: E501
[2] https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
[3] https://github.com/census-instrumentation/opencensus-cpp/blob/50eb5de762e5f87e206c011a4f930adb1a1775b1/opencensus/exporters/stats/prometheus/internal/prometheus_utils.cc#L39 # noqa: E501
[4] https://github.com/census-instrumentation/opencensus-proto/blob/master/src/opencensus/proto/metrics/v1/metrics.proto#L218 # noqa: E501
"""
if not metric.metric_descriptor.name.startswith("grpc.io/"):
return
metric.metric_descriptor.name = RE_NON_ALPHANUMS.sub(
"_", metric.metric_descriptor.name
)
for series in metric.timeseries:
for point in series.points:
if point.HasField("distribution_value"):
dist_value = point.distribution_value
bucket_bounds = dist_value.bucket_options.explicit.bounds
if len(bucket_bounds) > 0 and bucket_bounds[0] == 0:
bucket_bounds[0] = 0.000_000_1
class OpencensusProxyMetric:
def __init__(self, name: str, desc: str, unit: str, label_keys: List[str]):
"""Represents the OpenCensus metrics that will be proxy exported."""
self._name = name
self._desc = desc
self._unit = unit
# -- The label keys of the metric --
self._label_keys = label_keys
# -- The data that needs to be proxy exported --
# tuple of label values -> data (OpenCesnsus Aggregation data)
self._data = {}
@property
def name(self):
return self._name
@property
def desc(self):
return self._desc
@property
def unit(self):
return self._unit
@property
def label_keys(self):
return self._label_keys
@property
def data(self):
return self._data
def is_distribution_aggregation_data(self):
"""Check if the metric is a distribution aggreation metric."""
return len(self._data) > 0 and isinstance(
next(iter(self._data.values())), DistributionAggregationData
)
def add_data(self, label_values: Tuple, data: Any):
"""Add the data to the metric.
Args:
label_values: The label values of the metric.
data: The data to be added.
"""
self._data[label_values] = data
def record(self, metric: Metric):
"""Parse the Opencensus Protobuf and store the data.
The data can be accessed via `data` API once recorded.
"""
timeseries = metric.timeseries
if len(timeseries) == 0:
return
# Create the aggregation and fill it in the our stats
for series in timeseries:
labels = tuple(val.value for val in series.label_values)
# Aggregate points.
for point in series.points:
if (
metric.metric_descriptor.type
== MetricDescriptorType.CUMULATIVE_INT64
):
data = CountAggregationData(point.int64_value)
elif (
metric.metric_descriptor.type
== MetricDescriptorType.CUMULATIVE_DOUBLE
):
data = SumAggregationData(ValueDouble, point.double_value)
elif metric.metric_descriptor.type == MetricDescriptorType.GAUGE_DOUBLE:
data = LastValueAggregationData(ValueDouble, point.double_value)
elif (
metric.metric_descriptor.type
== MetricDescriptorType.CUMULATIVE_DISTRIBUTION
):
dist_value = point.distribution_value
counts_per_bucket = [bucket.count for bucket in dist_value.buckets]
bucket_bounds = dist_value.bucket_options.explicit.bounds
data = DistributionAggregationData(
dist_value.sum / dist_value.count,
dist_value.count,
dist_value.sum_of_squared_deviation,
counts_per_bucket,
bucket_bounds,
)
else:
raise ValueError("Summary is not supported")
self._data[labels] = data
class Component:
def __init__(self, id: str):
"""Represent a component that requests to proxy export metrics
Args:
id: Id of this component.
"""
self.id = id
# -- The time this component reported its metrics last time --
# It is used to figure out if this component is stale.
self._last_reported_time = time.monotonic()
# -- Metrics requested to proxy export from this component --
# metrics_name (str) -> metric (OpencensusProxyMetric)
self._metrics = {}
@property
def metrics(self) -> Dict[str, OpencensusProxyMetric]:
"""Return the metrics requested to proxy export from this component."""
return self._metrics
@property
def last_reported_time(self):
return self._last_reported_time
def record(self, metrics: List[Metric]):
"""Parse the Opencensus protobuf and store metrics.
Metrics can be accessed via `metrics` API for proxy export.
Args:
metrics: A list of Opencensus protobuf for proxy export.
"""
self._last_reported_time = time.monotonic()
for metric in metrics:
fix_grpc_metric(metric)
descriptor = metric.metric_descriptor
name = descriptor.name
label_keys = [label_key.key for label_key in descriptor.label_keys]
if name not in self._metrics:
self._metrics[name] = OpencensusProxyMetric(
name, descriptor.description, descriptor.unit, label_keys
)
self._metrics[name].record(metric)
class OpenCensusProxyCollector:
def __init__(self, namespace: str, component_timeout_s: int = 60):
"""Prometheus collector implementation for opencensus proxy export.
Prometheus collector requires to implement `collect` which is
invoked whenever Prometheus queries the endpoint.
The class is thread-safe.
Args:
namespace: Prometheus namespace.
component_timeout_s: Number of seconds after which a component
without new reports is considered stale and its metrics are
no longer exported.
"""
# -- Protect `self._components` --
self._components_lock = threading.Lock()
# -- Timeout until the component is marked as stale --
# Once the component is considered as stale,
# the metrics from that worker won't be exported.
self._component_timeout_s = component_timeout_s
# -- Prometheus namespace --
self._namespace = namespace
# -- Component that requests to proxy export metrics --
# Component means core worker, raylet, and GCS.
# component_id -> Components
# For workers, they contain worker ids.
# For other components (raylet, GCS),
# they contain the global key `GLOBAL_COMPONENT_KEY`.
self._components = {}
# Whether we want to export counter as gauge.
# This is for bug compatibility.
# See https://github.com/ray-project/ray/pull/43795.
self._export_counter_as_gauge = env_bool("RAY_EXPORT_COUNTER_AS_GAUGE", True)
def record(self, metrics: List[Metric], worker_id_hex: str = None):
"""Record the metrics reported from the component that reports it.
Args:
metrics: A list of opencensus protobuf to proxy export metrics.
worker_id_hex: A worker id that reports these metrics.
If None, it means they are reported from Raylet or GCS.
"""
key = GLOBAL_COMPONENT_KEY if not worker_id_hex else worker_id_hex
with self._components_lock:
if key not in self._components:
self._components[key] = Component(key)
self._components[key].record(metrics)
def clean_stale_components(self):
"""Clean up stale components.
Stale means the component is dead or unresponsive.
Stale components won't be reported to Prometheus anymore.
"""
with self._components_lock:
stale_components = []
stale_component_ids = []
for id, component in self._components.items():
elapsed = time.monotonic() - component.last_reported_time
if elapsed > self._component_timeout_s:
stale_component_ids.append(id)
logger.info(
"Metrics from a worker ({}) is cleaned up due to "
"timeout. Time since last report {}s".format(id, elapsed)
)
for id in stale_component_ids:
stale_components.append(self._components.pop(id))
return stale_components
# TODO(sang): add start and end timestamp
def to_prometheus_metrics(
self,
metric_name: str,
metric_description: str,
label_keys: List[str],
metric_units: str,
label_values: Tuple[tag_value_module.TagValue],
agg_data: Any,
metrics_map: Dict[str, List[PrometheusMetric]],
) -> None:
"""to_metric translate the data that OpenCensus create
to Prometheus format, using Prometheus Metric object.
This method is from Opencensus Prometheus Exporter.
Args:
metric_name: Name of the metric.
metric_description: Description of the metric.
label_keys: The fixed label keys of the metric.
metric_units: Units of the metric.
label_values: The values of `label_keys`.
agg_data: `opencensus.stats.aggregation_data.AggregationData` object.
Aggregated data that needs to be converted as Prometheus samples
metrics_map: The converted metric is added to this map.
"""
assert self._components_lock.locked()
metric_name = f"{self._namespace}_{metric_name}"
assert len(label_values) == len(label_keys), (label_values, label_keys)
# Prometheus requires that all tag values be strings hence
# the need to cast none to the empty string before exporting. See
# https://github.com/census-instrumentation/opencensus-python/issues/480
label_values = [tv if tv else "" for tv in label_values]
if isinstance(agg_data, CountAggregationData):
metrics = metrics_map.get(metric_name)
if not metrics:
metric = CounterMetricFamily(
name=metric_name,
documentation=metric_description,
unit=metric_units,
labels=label_keys,
)
metrics = [metric]
metrics_map[metric_name] = metrics
metrics[0].add_metric(labels=label_values, value=agg_data.count_data)
return
if isinstance(agg_data, SumAggregationData):
# This should be emitted as prometheus counter
# but we used to emit it as prometheus gauge.
# To keep the backward compatibility
# (changing from counter to gauge changes the metric name
# since prometheus client will add "_total" suffix to counter
# per OpenMetrics specification),
# we now emit both counter and gauge and in the
# next major Ray release (3.0) we can stop emitting gauge.
# This leaves people enough time to migrate their dashboards.
# See https://github.com/ray-project/ray/pull/43795.
metrics = metrics_map.get(metric_name)
if not metrics:
metric = CounterMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics = [metric]
metrics_map[metric_name] = metrics
metrics[0].add_metric(labels=label_values, value=agg_data.sum_data)
if not self._export_counter_as_gauge:
pass
elif metric_name.endswith("_total"):
# In this case, we only need to emit prometheus counter
# since for metric name already ends with _total suffix
# prometheus client won't change it
# so there is no backward compatibility issue.
# See https://prometheus.github.io/client_python/instrumenting/counter/
pass
else:
if len(metrics) == 1:
metric = GaugeMetricFamily(
name=metric_name,
documentation=(
f"(DEPRECATED, use {metric_name}_total metric instead) "
f"{metric_description}"
),
labels=label_keys,
)
metrics.append(metric)
assert len(metrics) == 2
metrics[1].add_metric(labels=label_values, value=agg_data.sum_data)
return
elif isinstance(agg_data, DistributionAggregationData):
assert agg_data.bounds == sorted(agg_data.bounds)
# buckets are a list of buckets. Each bucket is another list with
# a pair of bucket name and value, or a triple of bucket name,
# value, and exemplar. buckets need to be in order.
buckets = []
cum_count = 0 # Prometheus buckets expect cumulative count.
for ii, bound in enumerate(agg_data.bounds):
cum_count += agg_data.counts_per_bucket[ii]
bucket = [str(bound), cum_count]
buckets.append(bucket)
# Prometheus requires buckets to be sorted, and +Inf present.
# In OpenCensus we don't have +Inf in the bucket bonds so need to
# append it here.
buckets.append(["+Inf", agg_data.count_data])
metrics = metrics_map.get(metric_name)
if not metrics:
metric = HistogramMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics = [metric]
metrics_map[metric_name] = metrics
metrics[0].add_metric(
labels=label_values,
buckets=buckets,
sum_value=agg_data.sum,
)
return
elif isinstance(agg_data, LastValueAggregationData):
metrics = metrics_map.get(metric_name)
if not metrics:
metric = GaugeMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics = [metric]
metrics_map[metric_name] = metrics
metrics[0].add_metric(labels=label_values, value=agg_data.value)
return
else:
raise ValueError(f"unsupported aggregation type {type(agg_data)}")
def _aggregate_metric_data(
self,
datas: List[
Union[LastValueAggregationData, CountAggregationData, SumAggregationData]
],
) -> Union[LastValueAggregationData, CountAggregationData, SumAggregationData]:
assert len(datas) > 0
sample = datas[0]
if isinstance(sample, LastValueAggregationData):
return LastValueAggregationData(
ValueDouble, sum([data.value for data in datas])
)
if isinstance(sample, CountAggregationData):
return CountAggregationData(sum([data.count_data for data in datas]))
if isinstance(sample, SumAggregationData):
return SumAggregationData(
ValueDouble, sum([data.sum_data for data in datas])
)
raise ValueError(
f"Unsupported aggregation type {type(sample)}. "
"Supported types are "
f"{CountAggregationData}, {LastValueAggregationData}, {SumAggregationData}."
f"Got {datas}."
)
def _aggregate_with_recommended_cardinality(
self,
per_worker_metrics: List[OpencensusProxyMetric],
) -> List[OpencensusProxyMetric]:
"""Collect per-worker metrics, aggregate them into per-node metrics and convert
them to Prometheus format.
Args:
per_worker_metrics: A list of per-worker metrics for the same metric name.
Returns:
A list of per-node metrics for the same metric name, with the high
cardinality labels removed and the values aggregated.
"""
metric = next(iter(per_worker_metrics), None)
if not metric or WORKER_ID_TAG_KEY not in metric.label_keys:
# No high cardinality labels, return the original metrics.
return per_worker_metrics
worker_id_label_index = metric.label_keys.index(WORKER_ID_TAG_KEY)
# map from the tuple of label values without worker_id to the list of per worker
# task metrics
label_value_to_data: Dict[
Tuple,
List[
Union[
LastValueAggregationData,
CountAggregationData,
SumAggregationData,
]
],
] = defaultdict(list)
for metric in per_worker_metrics:
for label_values, data in metric.data.items():
# remove the worker_id from the label values
label_value_to_data[
label_values[:worker_id_label_index]
+ label_values[worker_id_label_index + 1 :]
].append(data)
aggregated_metric = OpencensusProxyMetric(
name=metric.name,
desc=metric.desc,
unit=metric.unit,
# remove the worker_id from the label keys
label_keys=metric.label_keys[:worker_id_label_index]
+ metric.label_keys[worker_id_label_index + 1 :],
)
for label_values, datas in label_value_to_data.items():
aggregated_metric.add_data(
label_values,
self._aggregate_metric_data(datas),
)
return [aggregated_metric]
def collect(self): # pragma: NO COVER
"""Collect fetches the statistics from OpenCensus
and delivers them as Prometheus Metrics.
Collect is invoked every time a prometheus.Gatherer is run
for example when the HTTP endpoint is invoked by Prometheus.
This method is required as a Prometheus Collector.
"""
with self._components_lock:
# First construct the list of opencensus metrics to be converted to
# prometheus metrics. For LEGACY cardinality level, this comprises all
# metrics from all components. For RECOMMENDED cardinality level, we need
# to remove the high cardinality labels and aggreate the component metrics.
open_cencus_metrics: List[OpencensusProxyMetric] = []
# The metrics that need to be aggregated with recommended cardinality. Key
# is the metric name and value is the list of per-worker metrics.
to_lower_cardinality: Dict[str, List[OpencensusProxyMetric]] = defaultdict(
list
)
cardinality_level = MetricCardinality.get_cardinality_level()
for component in self._components.values():
for metric in component.metrics.values():
if (
cardinality_level == MetricCardinality.RECOMMENDED
and not metric.is_distribution_aggregation_data()
):
# We reduce the cardinality for all metrics except for histogram
# metrics. The aggregation of histogram metrics from worker
# level to node level is not well defined. In addition, we
# currently have very few histogram metrics in Ray
# so the impact of them is negligible.
to_lower_cardinality[metric.name].append(metric)
else:
open_cencus_metrics.append(metric)
for per_worker_metrics in to_lower_cardinality.values():
open_cencus_metrics.extend(
self._aggregate_with_recommended_cardinality(
per_worker_metrics,
)
)
prometheus_metrics_map = {}
for metric in open_cencus_metrics:
for label_values, data in metric.data.items():
self.to_prometheus_metrics(
metric.name,
metric.desc,
metric.label_keys,
metric.unit,
label_values,
data,
prometheus_metrics_map,
)
for metrics in prometheus_metrics_map.values():
for metric in metrics:
yield metric
class MetricsAgent:
def __init__(
self,
view_manager: ViewManager,
stats_recorder: StatsRecorder,
stats_exporter: StatsExporter = None,
):
"""A class to record and export metrics.
The class exports metrics in 2 different ways.
- Directly record and export metrics using OpenCensus.
- Proxy metrics from other core components
(e.g., raylet, GCS, core workers).
This class is thread-safe.
"""
# Lock required because gRPC server uses
# multiple threads to process requests.
self._lock = threading.Lock()
#
# Opencensus components to record metrics.
#
# Managing views to export metrics
# If the stats_exporter is None, we disable all metrics export.
self.view_manager = view_manager
# A class that's used to record metrics
# emitted from the current process.
self.stats_recorder = stats_recorder
# A class to export metrics.
self.stats_exporter = stats_exporter
# -- A Prometheus custom collector to proxy export metrics --
# `None` if the prometheus server is not started.
self.proxy_exporter_collector = None
if self.stats_exporter is None:
# If the exporter is not given,
# we disable metrics collection.
self.view_manager = None
else:
self.view_manager.register_exporter(stats_exporter)
self.proxy_exporter_collector = OpenCensusProxyCollector(
self.stats_exporter.options.namespace,
component_timeout_s=int(os.getenv(RAY_WORKER_TIMEOUT_S, 120)),
)
# Registered view names.
self._registered_views: Set[str] = set()
def record_and_export(self, records: List[Record], global_tags=None):
"""Directly record and export stats from the same process."""
global_tags = global_tags or {}
with self._lock:
if not self.view_manager:
return
for record in records:
gauge = record.gauge
value = record.value
tags = record.tags
try:
self._record_gauge(gauge, value, {**tags, **global_tags})
except Exception as e:
logger.error(
f"Failed to record metric {gauge.name} with value {value} with tags {tags!r} and global tags {global_tags!r} due to: {e!r}"
)
def _record_gauge(self, gauge: Gauge, value: float, tags: dict):
if gauge.name not in self._registered_views:
self.view_manager.register_view(gauge.view)
self._registered_views.add(gauge.name)
measurement_map = self.stats_recorder.new_measurement_map()
tag_map = tag_map_module.TagMap()
for key, tag_val in tags.items():
try:
tag_key = tag_key_module.TagKey(key)
except ValueError as e:
logger.error(
f"Failed to create tag key {key} for metric {gauge.name} due to: {e!r}"
)
raise e
try:
tag_value = tag_value_module.TagValue(tag_val)
except ValueError as e:
logger.error(
f"Failed to create tag value {tag_val} for key {key} for metric {gauge.name} due to: {e!r}"
)
raise e
tag_map.insert(tag_key, tag_value)
measurement_map.measure_float_put(gauge.measure, value)
# NOTE: When we record this metric, timestamp will be renewed.
measurement_map.record(tag_map)
def proxy_export_metrics(self, metrics: List[Metric], worker_id_hex: str = None):
"""Proxy export metrics specified by a Opencensus Protobuf.
This API is used to export metrics emitted from
core components.
Args:
metrics: A list of protobuf Metric defined from OpenCensus.
worker_id_hex: The worker ID it proxies metrics export. None
if the metric is not from a worker (i.e., raylet, GCS).
Returns:
None.
"""
with self._lock:
if not self.view_manager:
return
self._proxy_export_metrics(metrics, worker_id_hex)
def _proxy_export_metrics(self, metrics: List[Metric], worker_id_hex: str = None):
self.proxy_exporter_collector.record(metrics, worker_id_hex)
def clean_all_dead_worker_metrics(self):
"""Clean dead worker's metrics.
Worker metrics are cleaned up and won't be exported once
it is considered as dead.
This method has to be periodically called by a caller.
"""
with self._lock:
if not self.view_manager:
return
self.proxy_exporter_collector.clean_stale_components()
class PrometheusServiceDiscoveryWriter(threading.Thread):
"""A class to support Prometheus service discovery.
It supports file-based service discovery. Checkout
https://prometheus.io/docs/guides/file-sd/ for more details.
Args:
gcs_address: Gcs address for this cluster.
temp_dir: Temporary directory used by
Ray to store logs and metadata.
session_dir: Session-specific directory for this Ray session.
If provided, the discovery file is written here instead of
temp_dir, and a backward-compatible symlink is created at
the old temp_dir location.
"""
def __init__(self, gcs_address: str, temp_dir: str, session_dir: str = None):
gcs_client_options = ray._raylet.GcsClientOptions.create(
gcs_address, None, allow_cluster_id_nil=True, fetch_cluster_id_if_nil=False
)
self.gcs_address = gcs_address
ray._private.state.state._initialize_global_state(gcs_client_options)
self.temp_dir = temp_dir
self.session_dir = session_dir if session_dir else temp_dir
# Tracks whether the backward-compatible symlink has been successfully created.
# This prevents recreating the symlink on every periodic write, avoiding
# unnecessary disk I/O, race conditions, and log flooding.
self._symlink_created = False
# If symlink creation fails (e.g., due to lack of permissions on Windows
# without developer mode, or restricted filesystems), this fallback flag is set
# to True. When True, the writer copies the file directly instead of symlinking.
self._use_fallback_copy = False
self.default_service_discovery_flush_period = 5
# The last service discovery content that PrometheusServiceDiscoveryWriter has seen
self.latest_service_discovery_content = []
self._content_lock = threading.RLock()
super().__init__()
def get_latest_service_discovery_content(self):
"""Return the latest stored service discovery content."""
with self._content_lock:
return self.latest_service_discovery_content
def get_file_discovery_content(self):
"""Return the content for Prometheus service discovery."""
nodes = ray.nodes()
metrics_export_addresses = [
build_address(node["NodeManagerAddress"], node["MetricsExportPort"])
for node in nodes
if node["alive"] is True
]
gcs_client = GcsClient(address=self.gcs_address)
autoscaler_addr = gcs_client.internal_kv_get(b"AutoscalerMetricsAddress", None)
if autoscaler_addr:
metrics_export_addresses.append(autoscaler_addr.decode("utf-8"))
dashboard_addr = gcs_client.internal_kv_get(b"DashboardMetricsAddress", None)
if dashboard_addr:
metrics_export_addresses.append(dashboard_addr.decode("utf-8"))
content = [{"labels": {"job": "ray"}, "targets": metrics_export_addresses}]
with self._content_lock:
self.latest_service_discovery_content = content
return json.dumps(content)
def write(self):
# Write a file based on https://prometheus.io/docs/guides/file-sd/
# Write should be atomic. Otherwise, Prometheus raises an error that
# json file format is invalid because it reads a file when
# file is re-written. Note that Prometheus still works although we
# have this error.
temp_file_name = self.get_temp_file_name()
with open(temp_file_name, "w") as json_file:
json_file.write(self.get_file_discovery_content())
# NOTE: os.replace is atomic on both Linux and Windows, so we won't
# have race condition reading this file.
os.replace(temp_file_name, self.get_target_file_name())
# Create a backward-compatible symlink at the old temp_dir location
# so that existing Prometheus configurations that reference the old
# path continue to work. Verify if the symlink is still valid and
# pointing to the correct target, repairing it if it has been deleted or modified.
if self.session_dir != self.temp_dir:
legacy_path = os.path.join(
self.temp_dir,
ray._private.ray_constants.PROMETHEUS_SERVICE_DISCOVERY_FILE,
)
if self._symlink_created and not self._use_fallback_copy:
try:
if not (
os.path.islink(legacy_path)
and os.readlink(legacy_path) == self.get_target_file_name()
):
self._symlink_created = False
except OSError:
self._symlink_created = False
if not self._symlink_created and not self._use_fallback_copy:
try:
if os.path.islink(legacy_path) or os.path.exists(legacy_path):
os.remove(legacy_path)
os.symlink(self.get_target_file_name(), legacy_path)
self._symlink_created = True
except OSError:
logger.warning(
f"Failed to create backward-compatible symlink at "
f"{legacy_path}. Falling back to copying the service discovery file."
)
self._use_fallback_copy = True
if self._use_fallback_copy:
try:
import shutil
temp_legacy_path = legacy_path + ".tmp"
shutil.copy(self.get_target_file_name(), temp_legacy_path)
os.replace(temp_legacy_path, legacy_path)
except OSError as e:
logger.warning(
f"Failed to copy service discovery file to legacy path {legacy_path}: {e}"
)
def get_target_file_name(self):
return os.path.join(
self.session_dir,
ray._private.ray_constants.PROMETHEUS_SERVICE_DISCOVERY_FILE,
)
def get_temp_file_name(self):
return os.path.join(
self.session_dir,
"{}_{}".format(
"tmp", ray._private.ray_constants.PROMETHEUS_SERVICE_DISCOVERY_FILE
),
)
def run(self):
while True:
# This thread won't be broken by exceptions.
try:
self.write()
except Exception as e:
logger.warning(
"Writing a service discovery file, {},failed.".format(
self.get_target_file_name()
)
)
logger.warning(traceback.format_exc())
logger.warning(f"Error message: {e}")
time.sleep(self.default_service_discovery_flush_period)
File diff suppressed because it is too large Load Diff
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import asyncio
import collections
from typing import TYPE_CHECKING, Deque, Iterator, Optional
import ray
from ray.exceptions import GetTimeoutError, ObjectRefStreamEndOfStreamError
from ray.util.annotations import DeveloperAPI, PublicAPI
if TYPE_CHECKING:
from ray._private.worker import Worker
@DeveloperAPI
class DynamicObjectRefGenerator:
def __init__(self, refs: Deque["ray.ObjectRef"]):
# TODO(swang): As an optimization, can also store the generator
# ObjectID so that we don't need to keep individual ref counts for the
# inner ObjectRefs.
self._refs: Deque["ray.ObjectRef"] = collections.deque(refs)
def __iter__(self) -> Iterator("ray.ObjectRef"):
while self._refs:
yield self._refs.popleft()
def __len__(self) -> int:
return len(self._refs)
@PublicAPI
class ObjectRefGenerator:
"""A generator to obtain object references from a task in a streaming manner.
The class is compatible with the Python generator and async generator interfaces.
The class is not thread-safe.
Do not initialize the class and create an instance directly.
The instance should be created by `.remote`.
.. testcode::
import ray
from typing import Generator
@ray.remote(num_returns="streaming")
def gen() -> Generator[int, None, None]:
for i in range(5):
yield i
obj_ref_gen: ray.ObjectRefGenerator = gen.remote()
for obj_ref in obj_ref_gen:
print("Got:", ray.get(obj_ref))
"""
def __init__(self, generator_ref: "ray.ObjectRef", worker: "Worker"):
# The reference to a generator task.
self._generator_ref = generator_ref
# True if an exception has been raised from the generator task.
self._generator_task_raised = False
# Ray's worker class. ray._private.worker.global_worker
self.worker = worker
self.worker.check_connected()
assert hasattr(worker, "core_worker")
# Public APIs
def __iter__(self) -> "ObjectRefGenerator":
return self
def __next__(self) -> "ray.ObjectRef":
"""Waits until a next ref is available and returns the object ref.
Raises StopIteration if there's no more objects
to generate.
The object ref will contain an exception if the task fails.
When the generator task returns N objects, it can return
up to N + 1 objects (if there's a system failure, the
last object will contain a system level exception).
"""
return self._next_sync()
def send(self, value):
raise NotImplementedError("`gen.send` is not supported.")
def throw(self, value):
raise NotImplementedError("`gen.throw` is not supported.")
def close(self):
raise NotImplementedError("`gen.close` is not supported.")
def __aiter__(self) -> "ObjectRefGenerator":
return self
async def __anext__(self):
return await self._next_async()
async def asend(self, value):
raise NotImplementedError("`gen.asend` is not supported.")
async def athrow(self, value):
raise NotImplementedError("`gen.athrow` is not supported.")
async def aclose(self):
raise NotImplementedError("`gen.aclose` is not supported.")
def completed(self) -> "ray.ObjectRef":
"""Returns an object ref that is ready when
a generator task completes.
If the task is failed unexpectedly (e.g., worker failure),
the `ray.get(gen.completed())` raises an exception.
The function returns immediately.
"""
return self._generator_ref
def next_ready(self) -> bool:
"""If True, it means the output of next(gen) is ready and
ray.get(next(gen)) returns immediately. False otherwise.
It returns False when next(gen) raises a StopIteration
(this condition should be checked using is_finished).
The function returns immediately.
"""
self.worker.check_connected()
core_worker = self.worker.core_worker
if self.is_finished():
return False
expected_ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if is_ready:
return True
ready, _ = ray.wait([expected_ref], timeout=0, fetch_local=False)
return len(ready) > 0
def is_finished(self) -> bool:
"""If True, it means the generator is finished
and all output is taken. False otherwise.
When True, if next(gen) is called, it will raise StopIteration
or StopAsyncIteration
The function returns immediately.
"""
self.worker.check_connected()
core_worker = self.worker.core_worker
finished = core_worker.is_object_ref_stream_finished(self._generator_ref)
if finished:
if self._generator_task_raised:
return True
else:
# We should try ray.get on a generator ref.
# If it raises an exception and
# _generator_task_raised is not set,
# this means the last ref is not taken yet.
try:
ray.get(self._generator_ref)
except Exception:
# The exception from _generator_ref
# hasn't been taken yet.
return False
else:
return True
else:
return False
# Private APIs
def _get_next_object_id_binary(self) -> bytes:
"""Return the binary id of the next object in the stream."""
self.worker.check_connected()
return self.worker.core_worker.peek_next_object_id_binary(self._generator_ref)
def _stream_exhausted(self) -> bool:
"""Whether the stream's end-of-stream marker has been reached and all
yielded refs consumed.
Non-blocking, in-memory check (unlike ``is_finished``, this does not
``ray.get`` the generator return object). When True, the only thing
left is the end-of-stream ``ray.get`` of the return object that
``_next_sync`` performs to surface ``StopIteration`` / task errors.
"""
self.worker.check_connected()
return self.worker.core_worker.is_object_ref_stream_finished(
self._generator_ref
)
def _get_next_ref_n(self, num_refs: int) -> list["ray.ObjectRef"]:
"""Return the next num_refs references from a generator without consuming them.
The returned refs are not consumed; wait for the last one to become ready
before calling ``_consume_next_ref_n`` to advance the stream.
Args:
num_refs: The number of references to return, starting from the
current head of the stream. Must be positive.
Returns:
A list of exactly num_refs ObjectRefs corresponding to the next
results in the stream, starting from the current head.
"""
if num_refs <= 0:
raise ValueError("num_refs must be positive")
self.worker.check_connected()
core_worker = self.worker.core_worker
return [
ref
for ref, _ in core_worker.peek_object_ref_stream_n(
self._generator_ref, num_refs
)
]
def _consume_next_ref_n(self, num_refs: int) -> None:
"""Consume (advance) the next num_refs references from a generator.
The caller must have waited for the last requested ref to become ready
(see ``_get_next_ref_n``); otherwise this raises ``ValueError`` instead
of silently advancing past unwritten objects.
If fewer than num_refs references remain before the end of the stream,
only the remaining references are consumed and the call returns
without raising.
Args:
num_refs: The number of references to consume, starting from the
current head of the stream. Must be positive.
"""
if num_refs <= 0:
raise ValueError("num_refs must be positive")
self.worker.check_connected()
core_worker = self.worker.core_worker
try:
core_worker.try_read_next_object_ref_stream_n(self._generator_ref, num_refs)
except ObjectRefStreamEndOfStreamError:
return
def _next_sync(self, timeout_s: Optional[int | float] = None) -> "ray.ObjectRef":
"""Waits for timeout_s and returns the object ref if available.
If an object is not available within the given timeout, it
returns a nil object reference.
If -1 timeout is provided, it means it waits infinitely.
Waiting is implemented as busy waiting.
Raises StopIteration if there's no more objects
to generate.
The object ref will contain an exception if the task fails.
When the generator task returns N objects, it can return
up to N + 1 objects (if there's a system failure, the
last object will contain a system level exception).
Args:
timeout_s: If the next object is not ready within
this timeout, it returns the nil object ref.
Returns:
ObjectRef corresponding to the next result in the stream.
"""
core_worker = self.worker.core_worker
# Wait for the next ObjectRef to become ready.
expected_ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if not is_ready:
_, unready = ray.wait([expected_ref], timeout=timeout_s, fetch_local=False)
if len(unready) > 0:
return ray.ObjectRef.nil()
try:
ref = core_worker.try_read_next_object_ref_stream(self._generator_ref)
assert not ref.is_nil()
except ObjectRefStreamEndOfStreamError:
if self._generator_task_raised:
# Exception has been returned.
raise StopIteration from None
try:
# The generator ref contains an exception
# if there's any failure. It contains nothing otherwise.
# In that case, it should raise StopIteration.
#
# Bound this get by the caller's timeout: the return object
# can be remote — or lost to a failed node and pending
# reconstruction — and an unbounded get would block the
# caller until it is restored (e.g. the Ray Data scheduling
# thread; a saturated cluster can then deadlock, since the
# blocked consumer is what releases backpressured CPUs).
# Per this method's contract, a timeout is reported as "no
# object ready yet" (nil ref) so the caller retries.
ray.get(
self._generator_ref,
timeout=(None if timeout_s is None or timeout_s < 0 else timeout_s),
)
except GetTimeoutError:
return ray.ObjectRef.nil()
except Exception:
self._generator_task_raised = True
return self._generator_ref
else:
# The task finished without an exception.
raise StopIteration from None
return ref
async def _suppress_exceptions(self, ref: "ray.ObjectRef") -> None:
# Wrap a streamed ref to avoid asyncio warnings about not retrieving
# the exception when we are just waiting for the ref to become ready.
# The exception will get returned (or warned) to the user once they
# actually await the ref.
try:
await ref
except Exception:
pass
async def _next_async(self, timeout_s: Optional[int | float] = None):
"""Same API as _next_sync, but it is for async context."""
core_worker = self.worker.core_worker
ref, is_ready = core_worker.peek_object_ref_stream(self._generator_ref)
if not is_ready:
# TODO(swang): Avoid fetching the value.
_, unready = await asyncio.wait(
[asyncio.create_task(self._suppress_exceptions(ref))], timeout=timeout_s
)
if len(unready) > 0:
return ray.ObjectRef.nil()
try:
ref = core_worker.try_read_next_object_ref_stream(self._generator_ref)
assert not ref.is_nil()
except ObjectRefStreamEndOfStreamError:
if self._generator_task_raised:
# Exception has been returned.
raise StopAsyncIteration from None
try:
# The generator ref contains an exception
# if there's any failure. It contains nothing otherwise.
# In that case, it should raise StopAsyncIteration.
#
# Bound this await by the caller's timeout, mirroring
# _next_sync: the return object can be remote — or lost to a
# failed node and pending reconstruction — and an unbounded
# await would block the caller until it is restored. Per this
# method's contract, a timeout is reported as "no object
# ready yet" (nil ref) so the caller retries.
if timeout_s is None or timeout_s < 0:
await self._generator_ref
else:
await asyncio.wait_for(self._generator_ref, timeout=timeout_s)
except asyncio.TimeoutError:
return ray.ObjectRef.nil()
except Exception:
self._generator_task_raised = True
return self._generator_ref
else:
# Meaning the task succeed without failure raise StopAsyncIteration.
raise StopAsyncIteration from None
return ref
def __del__(self):
if hasattr(self.worker, "core_worker"):
# The stream is created when a task is first submitted.
# NOTE: This can be called multiple times
# because python doesn't guarantee __del__ is called
# only once.
self.worker.core_worker.async_delete_object_ref_stream(self._generator_ref)
def __getstate__(self):
raise TypeError(
"You cannot return or pass a generator to other task. "
"Serializing a ObjectRefGenerator is not allowed."
)
+482
View File
@@ -0,0 +1,482 @@
import logging
import os
import pathlib
from typing import Dict, List, Optional
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import get_localhost_ip
from ray._private.resource_isolation_config import ResourceIsolationConfig
from ray._private.utils import get_ray_client_dependency_error
logger = logging.getLogger(__name__)
class RayParams:
"""A class used to store the parameters used by Ray.
Attributes:
redis_address: The address of the Redis server to connect to. If
this address is not provided, then this command will start Redis, a
raylet, a plasma store, a plasma manager, and some workers.
It will also kill these processes when Python exits.
redis_port: The port that the primary Redis shard should listen
to. If None, then it will fall back to
ray._private.ray_constants.DEFAULT_PORT, or a random port if the default is
not available.
redis_shard_ports: A list of the ports to use for the non-primary Redis
shards. If None, then it will fall back to the ports right after
redis_port, or random ports if those are not available.
num_cpus: Number of CPUs to configure the raylet with.
num_gpus: Number of GPUs to configure the raylet with.
resources: A dictionary mapping the name of a resource to the quantity
of that resource available.
labels: The key-value labels of the node.
memory: Total available memory for workers requesting memory.
object_store_memory: The amount of memory (in bytes) to start the
object store with.
object_manager_port int: The port to use for the object manager.
node_manager_port: The port to use for the node manager.
gcs_server_port: The port to use for the GCS server.
node_ip_address: The IP address of the node that we are on.
min_worker_port: The lowest port number that workers will bind
on. If not set or set to 0, random ports will be chosen.
max_worker_port: The highest port number that workers will bind
on. If set, min_worker_port must also be set.
worker_port_list: An explicit list of ports to be used for
workers (comma-separated). Overrides min_worker_port and
max_worker_port.
ray_client_server_port: The port number the ray client server
will bind on. If not set, the ray client server will not
be started.
redirect_output: True if stdout and stderr for non-worker
processes should be redirected to files and false otherwise.
log_to_stderr: If set, controls whether non-worker stdout/stderr should be
written to stderr (True) or redirected to log files (False). This is the
preferred replacement for the deprecated `redirect_output` field.
external_addresses: The address of external Redis server to
connect to, in format of "ip1:port1,ip2:port2,...". If this
address is provided, then ray won't start Redis instances in the
head node but use external Redis server(s) instead.
num_redis_shards: The number of Redis shards to start in addition to
the primary Redis shard.
redis_max_clients: If provided, attempt to configure Redis with this
maxclients number.
redis_username: Prevents external clients without the username
from connecting to Redis if provided.
redis_password: Prevents external clients without the password
from connecting to Redis if provided.
plasma_directory: A directory where the Plasma memory mapped files will
be created.
object_spilling_directory: The path to spill objects to. The same path will
be used as the object store fallback directory as well.
worker_path: The path of the source code that will be run by the
worker.
setup_worker_path: The path of the Python file that will set up
the environment for the worker process.
huge_pages: Boolean flag indicating whether to start the Object
Store with hugetlbfs support. Requires plasma_directory.
include_dashboard: Boolean flag indicating whether to start the web
UI, which displays the status of the Ray cluster. If this value is
None, then the UI will be started if the relevant dependencies are
present.
dashboard_host: The host to bind the dashboard server to. Use localhost
(127.0.0.1/::1) for local access only, or 0.0.0.0/:: for all
interfaces. Defaults to localhost.
dashboard_port: The port to bind the dashboard server to.
Defaults to 8265.
dashboard_agent_listen_port: The port for dashboard agents to listen on
for HTTP requests.
Defaults to 52365.
runtime_env_agent_port: The port at which the runtime env agent
listens to for HTTP.
Defaults to random available port.
plasma_store_socket_name: If provided, it specifies the socket
name used by the plasma store.
raylet_socket_name: If provided, it specifies the socket path
used by the raylet process.
temp_dir: If provided, it will specify the root temporary
directory for the Ray process. Must be an absolute path.
runtime_env_dir_name: If provided, specifies the directory that
will be created in the session dir to hold runtime_env files.
include_log_monitor: If True, then start a log monitor to
monitor the log files for all processes on this node and push their
contents to Redis.
autoscaling_config: path to autoscaling config file.
metrics_agent_port: The port to bind metrics agent.
metrics_export_port: The port at which metrics are exposed
through a Prometheus endpoint.
no_monitor: If True, the ray autoscaler monitor for this cluster
will not be started.
_system_config: Configuration for overriding RayConfig
defaults. Used to set system configuration and for experimental Ray
core feature flags.
enable_object_reconstruction: Enable plasma reconstruction on
failure.
ray_debugger_external: If true, make the Ray debugger for a
worker available externally to the node it is running on. This will
bind on 0.0.0.0 instead of localhost.
env_vars: Override environment variables for the raylet.
session_name: The current Ray session name.
webui: The url of the UI.
cluster_id: The cluster ID in hex string.
resource_isolation_config: settings for cgroupv2 based isolation of ray
system processes (defaults to no isolation if config not provided)
proxy_server_url: The proxy url to redirect dashboard backend request to.
By default, the dashboard requests will be directed to the Ray api server.
Ex: http://historyserver:8080
"""
def __init__(
self,
redis_address: Optional[str] = None,
gcs_address: Optional[str] = None,
num_cpus: Optional[int] = None,
num_gpus: Optional[int] = None,
resources: Optional[Dict[str, float]] = None,
labels: Optional[Dict[str, str]] = None,
memory: Optional[float] = None,
object_store_memory: Optional[float] = None,
redis_port: Optional[int] = None,
redis_shard_ports: Optional[List[int]] = None,
object_manager_port: Optional[int] = None,
node_manager_port: int = 0,
gcs_server_port: Optional[int] = None,
node_ip_address: Optional[str] = None,
node_name: Optional[str] = None,
min_worker_port: Optional[int] = None,
max_worker_port: Optional[int] = None,
worker_port_list: Optional[List[int]] = None,
ray_client_server_port: Optional[int] = None,
redirect_output: Optional[bool] = None,
log_to_stderr: Optional[bool] = None,
external_addresses: Optional[List[str]] = None,
num_redis_shards: Optional[int] = None,
redis_max_clients: Optional[int] = None,
redis_username: Optional[str] = ray_constants.REDIS_DEFAULT_USERNAME,
redis_password: Optional[str] = ray_constants.REDIS_DEFAULT_PASSWORD,
plasma_directory: Optional[str] = None,
object_spilling_directory: Optional[str] = None,
worker_path: Optional[str] = None,
setup_worker_path: Optional[str] = None,
huge_pages: Optional[bool] = False,
include_dashboard: Optional[bool] = None,
dashboard_host: Optional[str] = get_localhost_ip(),
dashboard_port: Optional[bool] = ray_constants.DEFAULT_DASHBOARD_PORT,
dashboard_agent_listen_port: Optional[
int
] = ray_constants.DEFAULT_DASHBOARD_AGENT_LISTEN_PORT,
runtime_env_agent_port: Optional[int] = None,
plasma_store_socket_name: Optional[str] = None,
raylet_socket_name: Optional[str] = None,
temp_dir: Optional[str] = None,
runtime_env_dir_name: Optional[str] = None,
include_log_monitor: Optional[str] = None,
autoscaling_config: Optional[str] = None,
ray_debugger_external: bool = False,
_system_config: Optional[Dict[str, str]] = None,
enable_object_reconstruction: Optional[bool] = False,
metrics_agent_port: Optional[int] = None,
metrics_export_port: Optional[int] = None,
tracing_startup_hook=None,
no_monitor: Optional[bool] = False,
env_vars: Optional[Dict[str, str]] = None,
session_name: Optional[str] = None,
webui: Optional[str] = None,
cluster_id: Optional[str] = None,
node_id: Optional[str] = None,
resource_isolation_config: Optional[ResourceIsolationConfig] = None,
proxy_server_url: Optional[str] = None,
):
self.redis_address = redis_address
self.gcs_address = gcs_address
self.num_cpus = num_cpus
self.num_gpus = num_gpus
self.memory = memory
self.object_store_memory = object_store_memory
self.resources = resources
self.redis_port = redis_port
self.redis_shard_ports = redis_shard_ports
self.object_manager_port = object_manager_port
self.node_manager_port = node_manager_port
self.gcs_server_port = gcs_server_port
self.node_ip_address = node_ip_address
self.node_name = node_name
self.min_worker_port = min_worker_port
self.max_worker_port = max_worker_port
self.worker_port_list = worker_port_list
self.ray_client_server_port = ray_client_server_port
self.redirect_output = redirect_output
self.log_to_stderr = log_to_stderr
self.external_addresses = external_addresses
self.num_redis_shards = num_redis_shards
self.redis_max_clients = redis_max_clients
self.redis_username = redis_username
self.redis_password = redis_password
self.plasma_directory = plasma_directory
self.object_spilling_directory = object_spilling_directory
self.worker_path = worker_path
self.setup_worker_path = setup_worker_path
self.huge_pages = huge_pages
self.include_dashboard = include_dashboard
self.dashboard_host = dashboard_host
self.dashboard_port = dashboard_port
self.dashboard_agent_listen_port = dashboard_agent_listen_port
self.runtime_env_agent_port = runtime_env_agent_port
self.plasma_store_socket_name = plasma_store_socket_name
self.raylet_socket_name = raylet_socket_name
self.temp_dir = temp_dir
self.runtime_env_dir_name = (
runtime_env_dir_name or ray_constants.DEFAULT_RUNTIME_ENV_DIR_NAME
)
self.include_log_monitor = include_log_monitor
self.autoscaling_config = autoscaling_config
self.metrics_agent_port = metrics_agent_port
self.metrics_export_port = metrics_export_port
self.tracing_startup_hook = tracing_startup_hook
self.no_monitor = no_monitor
self.ray_debugger_external = ray_debugger_external
self.env_vars = env_vars
self.session_name = session_name
self.webui = webui
self._system_config = _system_config or {}
self._enable_object_reconstruction = enable_object_reconstruction
self.labels = labels
self._check_usage()
self.cluster_id = cluster_id
self.node_id = node_id
self.proxy_server_url = proxy_server_url
self.resource_isolation_config = resource_isolation_config
if not self.resource_isolation_config:
self.resource_isolation_config = ResourceIsolationConfig(
enable_resource_isolation=False
)
# Set the internal config options for object reconstruction.
if enable_object_reconstruction:
# Turn off object pinning.
if self._system_config is None:
self._system_config = dict()
print(self._system_config)
self._system_config["lineage_pinning_enabled"] = True
def update(self, **kwargs):
"""Update the settings according to the keyword arguments.
Args:
kwargs: The keyword arguments to set corresponding fields.
"""
for arg in kwargs:
if hasattr(self, arg):
setattr(self, arg, kwargs[arg])
else:
raise ValueError(f"Invalid RayParams parameter in update: {arg}")
self._check_usage()
def update_if_absent(self, **kwargs):
"""Update the settings when the target fields are None.
Args:
kwargs: The keyword arguments to set corresponding fields.
"""
for arg in kwargs:
if hasattr(self, arg):
if getattr(self, arg) is None:
setattr(self, arg, kwargs[arg])
else:
raise ValueError(
f"Invalid RayParams parameter in update_if_absent: {arg}"
)
self._check_usage()
def update_pre_selected_port(self):
"""Update the pre-selected port information
Returns:
The dictionary mapping of component -> ports.
"""
def wrap_port(port):
# 0 port means select a random port for the grpc server.
if port is None or port == 0:
return []
else:
return [port]
# Create a dictionary of the component -> port mapping.
pre_selected_ports = {
"gcs": wrap_port(self.redis_port),
"object_manager": wrap_port(self.object_manager_port),
"node_manager": wrap_port(self.node_manager_port),
"gcs_server": wrap_port(self.gcs_server_port),
"client_server": wrap_port(self.ray_client_server_port),
"dashboard": wrap_port(self.dashboard_port),
"dashboard_agent_grpc": wrap_port(self.metrics_agent_port),
"dashboard_agent_http": wrap_port(self.dashboard_agent_listen_port),
"runtime_env_agent": wrap_port(self.runtime_env_agent_port),
"metrics_export": wrap_port(self.metrics_export_port),
}
redis_shard_ports = self.redis_shard_ports
if redis_shard_ports is None:
redis_shard_ports = []
pre_selected_ports["redis_shards"] = redis_shard_ports
if self.worker_port_list is None:
if self.min_worker_port is not None and self.max_worker_port is not None:
pre_selected_ports["worker_ports"] = list(
range(self.min_worker_port, self.max_worker_port + 1)
)
else:
# The dict is not updated when it requires random ports.
pre_selected_ports["worker_ports"] = []
else:
pre_selected_ports["worker_ports"] = [
int(port) for port in self.worker_port_list.split(",")
]
# Update the pre selected port set.
self.reserved_ports = set()
for comp, port_list in pre_selected_ports.items():
for port in port_list:
if port in self.reserved_ports:
raise ValueError(
f"Ray component {comp} is trying to use "
f"a port number {port} that is used by other components.\n"
f"Port information: {self._format_ports(pre_selected_ports)}\n"
"If you allocate ports, please make sure the same port "
"is not used by multiple components."
)
self.reserved_ports.add(port)
def _check_usage(self):
if self.worker_port_list is not None:
for port_str in self.worker_port_list.split(","):
try:
port = int(port_str)
except ValueError as e:
raise ValueError(
"worker_port_list must be a comma-separated "
f"list of integers: {e}"
) from None
if port < 1024 or port > 65535:
raise ValueError(
"Ports in worker_port_list must be "
f"between 1024 and 65535. Got: {port}"
)
# Used primarily for testing.
if os.environ.get("RAY_USE_RANDOM_PORTS", False):
if self.min_worker_port is None and self.max_worker_port is None:
self.min_worker_port = 0
self.max_worker_port = 0
if self.min_worker_port is not None:
if self.min_worker_port != 0 and (
self.min_worker_port < 1024 or self.min_worker_port > 65535
):
raise ValueError(
"min_worker_port must be 0 or an integer between 1024 and 65535."
)
if self.max_worker_port is not None:
if self.min_worker_port is None:
raise ValueError(
"If max_worker_port is set, min_worker_port must also be set."
)
elif self.max_worker_port != 0:
if self.max_worker_port < 1024 or self.max_worker_port > 65535:
raise ValueError(
"max_worker_port must be 0 or an integer between "
"1024 and 65535."
)
elif self.max_worker_port <= self.min_worker_port:
raise ValueError(
"max_worker_port must be higher than min_worker_port."
)
if self.ray_client_server_port is not None:
if get_ray_client_dependency_error() is not None:
raise ValueError(
"Ray Client requires pip package `ray[client]`. "
"If you installed the minimal Ray (e.g. `pip install ray`), "
"please reinstall by executing `pip install ray[client]`."
)
if (
self.ray_client_server_port < 1024
or self.ray_client_server_port > 65535
):
raise ValueError(
"ray_client_server_port must be an integer "
"between 1024 and 65535."
)
if self.runtime_env_agent_port is not None:
if self.runtime_env_agent_port != 0 and (
self.runtime_env_agent_port < 1024
or self.runtime_env_agent_port > 65535
):
raise ValueError(
"runtime_env_agent_port must be 0 (auto-assign) or an integer "
"between 1024 and 65535."
)
if self.resources is not None:
def build_error(resource, alternative):
return (
f"{self.resources} -> `{resource}` cannot be a "
"custom resource because it is one of the default resources "
f"({ray_constants.DEFAULT_RESOURCES}). "
f"Use `{alternative}` instead. For example, use `ray start "
f"--{alternative.replace('_', '-')}=1` instead of "
f"`ray start --resources={{'{resource}': 1}}`"
)
assert "CPU" not in self.resources, build_error("CPU", "num_cpus")
assert "GPU" not in self.resources, build_error("GPU", "num_gpus")
assert "memory" not in self.resources, build_error("memory", "memory")
assert "object_store_memory" not in self.resources, build_error(
"object_store_memory", "object_store_memory"
)
if self.redirect_output is not None:
raise DeprecationWarning("The redirect_output argument is deprecated.")
if self.temp_dir is not None and not os.path.isabs(self.temp_dir):
raise ValueError("temp_dir must be absolute path or None.")
if self.temp_dir is not None and os.getenv("VIRTUAL_ENV"):
is_relative = True
try:
(
pathlib.Path(self.temp_dir)
.resolve()
.relative_to(pathlib.Path(os.getenv("VIRTUAL_ENV")).resolve())
)
except ValueError:
is_relative = False
if is_relative:
raise ValueError(
"temp_dir must not be child directory of virtualenv root"
)
def _format_ports(self, pre_selected_ports):
"""Format the pre-selected ports information to be more human-readable."""
ports = pre_selected_ports.copy()
for comp, port_list in ports.items():
if len(port_list) == 1:
ports[comp] = port_list[0]
elif len(port_list) == 0:
# Nothing is selected, meaning it will be randomly selected.
ports[comp] = "random"
elif comp == "worker_ports":
min_port = port_list[0]
max_port = port_list[len(port_list) - 1]
if len(port_list) < 50:
port_range_str = str(port_list)
else:
port_range_str = f"from {min_port} to {max_port}"
ports[comp] = f"{len(port_list)} ports {port_range_str}"
return ports
+32
View File
@@ -0,0 +1,32 @@
import pathlib
import urllib
"""Cross-platform utilities for manipulating paths and URIs.
NOTE: All functions in this file must support POSIX and Windows.
"""
def is_path(path_or_uri: str) -> bool:
"""Returns True if uri_or_path is a path and False otherwise.
Windows paths start with a drive name which can be interpreted as
a URI scheme by urlparse and thus needs to be treated differently
form POSIX paths.
E.g. Creating a directory returns the path 'C:\\Users\\mp5n6ul72w\\working_dir'
will have the scheme 'C:'.
"""
if not isinstance(path_or_uri, str):
raise TypeError(f" path_or_uri must be a string, got {type(path_or_uri)}.")
parsed_path = pathlib.Path(path_or_uri)
parsed_uri = urllib.parse.urlparse(path_or_uri)
if isinstance(parsed_path, pathlib.PurePosixPath):
return not parsed_uri.scheme
elif isinstance(parsed_path, pathlib.PureWindowsPath):
return parsed_uri.scheme == parsed_path.drive.strip(":").lower()
else:
# this should never happen.
raise TypeError(f"Unsupported path type: {type(parsed_path).__name__}")
+197
View File
@@ -0,0 +1,197 @@
import asyncio
import io
import logging
import os
import sys
from concurrent.futures import ThreadPoolExecutor
import ray
import ray._private.ray_constants as ray_constants
import ray.dashboard.consts as dashboard_consts
from ray._common.utils import run_background_task
from ray._raylet import GcsClient
from ray.dashboard.consts import _PARENT_DEATH_THREASHOLD
# Import psutil after ray so the packaged version is used.
import psutil
logger = logging.getLogger(__name__)
# TODO: move all consts from dashboard_consts to ray_constants and rename to remove
# DASHBOARD_ prefixes.
# Publishes at most this number of lines of Raylet logs, when the Raylet dies
# unexpectedly.
_RAYLET_LOG_MAX_PUBLISH_LINES = 20
# Reads at most this amount of Raylet logs from the tail, for publishing and
# checking if the Raylet was terminated gracefully.
_RAYLET_LOG_MAX_TAIL_SIZE = 1 * 1024**2
try:
create_task = asyncio.create_task
except AttributeError:
create_task = asyncio.ensure_future
def get_raylet_pid():
# TODO(edoakes): RAY_RAYLET_PID isn't properly set on Windows. This is
# only used for fate-sharing with the raylet and we need a different
# fate-sharing mechanism for Windows anyways.
if sys.platform in ["win32", "cygwin"]:
return None
raylet_pid = int(os.environ["RAY_RAYLET_PID"])
assert raylet_pid > 0
logger.info("raylet pid is %s", raylet_pid)
return raylet_pid
def create_check_raylet_task(log_dir, gcs_client, parent_dead_callback, loop):
"""
Creates an asyncio task to periodically check if the raylet process is still
running. If raylet is dead for _PARENT_DEATH_THREASHOLD (5) times, prepare to exit
as follows:
- Write logs about whether the raylet exit is graceful, by looking into the raylet
log and search for term "SIGTERM",
- Flush the logs via GcsClient,
- Exit.
"""
if sys.platform in ["win32", "cygwin"]:
raise RuntimeError("can't check raylet process in Windows.")
raylet_pid = get_raylet_pid()
if dashboard_consts.PARENT_HEALTH_CHECK_BY_PIPE:
logger.info("check_parent_via_pipe")
check_parent_task = _check_parent_via_pipe(
log_dir, gcs_client, loop, parent_dead_callback
)
else:
logger.info("_check_parent")
check_parent_task = _check_parent(
raylet_pid, log_dir, gcs_client, parent_dead_callback
)
return run_background_task(check_parent_task)
def report_raylet_error_logs(log_dir: str, gcs_client: GcsClient):
log_path = os.path.join(log_dir, "raylet.out")
error = False
msg = "Raylet is terminated. "
try:
with open(log_path, "r", encoding="utf-8") as f:
# Seek to _RAYLET_LOG_MAX_TAIL_SIZE from the end if the
# file is larger than that.
f.seek(0, io.SEEK_END)
pos = max(0, f.tell() - _RAYLET_LOG_MAX_TAIL_SIZE)
f.seek(pos, io.SEEK_SET)
# Read remaining logs by lines.
raylet_logs = f.readlines()
# Assume the SIGTERM message must exist within the last
# _RAYLET_LOG_MAX_TAIL_SIZE of the log file.
if any("Raylet received SIGTERM" in line for line in raylet_logs):
msg += "Termination is graceful."
logger.info(msg)
else:
msg += (
"Termination is unexpected. Possible reasons "
"include: (1) SIGKILL by the user or system "
"OOM killer, (2) Invalid memory access from "
"Raylet causing SIGSEGV or SIGBUS, "
"(3) Other termination signals. "
f"Last {_RAYLET_LOG_MAX_PUBLISH_LINES} lines "
"of the Raylet logs:\n"
)
msg += " " + " ".join(
raylet_logs[-_RAYLET_LOG_MAX_PUBLISH_LINES:]
)
error = True
except Exception as e:
msg += f"Failed to read Raylet logs at {log_path}: {e}!"
logger.exception(msg)
error = True
if error:
logger.error(msg)
# TODO: switch to async if necessary.
ray._private.utils.publish_error_to_driver(
ray_constants.RAYLET_DIED_ERROR,
msg,
gcs_client=gcs_client,
)
else:
logger.info(msg)
async def _check_parent_via_pipe(
log_dir: str, gcs_client: GcsClient, loop, parent_dead_callback
):
while True:
try:
# Read input asynchronously.
# The parent (raylet) should have redirected its pipe
# to stdin. If we read 0 bytes from stdin, it means
# the process is dead.
with ThreadPoolExecutor(max_workers=1) as executor:
input_data = await loop.run_in_executor(
executor, lambda: sys.stdin.readline()
)
if len(input_data) == 0:
# cannot read bytes from parent == parent is dead.
parent_dead_callback("_check_parent_via_pipe: The parent is dead.")
report_raylet_error_logs(log_dir, gcs_client)
sys.exit(0)
except Exception as e:
logger.exception(
"raylet health checking is failed. "
f"The agent process may leak. Exception: {e}"
)
async def _check_parent(raylet_pid, log_dir, gcs_client, parent_dead_callback):
"""Check if raylet is dead and fate-share if it is."""
try:
curr_proc = psutil.Process()
parent_death_cnt = 0
while True:
parent = curr_proc.parent()
# If the parent is dead, it is None.
parent_gone = parent is None
init_assigned_for_parent = False
parent_changed = False
if parent:
# Sometimes, the parent is changed to the `init` process.
# In this case, the parent.pid is 1.
init_assigned_for_parent = parent.pid == 1
# Sometimes, the parent is dead, and the pid is reused
# by other processes. In this case, this condition is triggered.
parent_changed = raylet_pid != parent.pid
if parent_gone or init_assigned_for_parent or parent_changed:
parent_death_cnt += 1
logger.warning(
f"Raylet is considered dead {parent_death_cnt} X. "
f"If it reaches to {_PARENT_DEATH_THREASHOLD}, the agent "
f"will kill itself. Parent: {parent}, "
f"parent_gone: {parent_gone}, "
f"init_assigned_for_parent: {init_assigned_for_parent}, "
f"parent_changed: {parent_changed}."
)
if parent_death_cnt < _PARENT_DEATH_THREASHOLD:
await asyncio.sleep(
dashboard_consts.DASHBOARD_AGENT_CHECK_PARENT_INTERVAL_S
)
continue
parent_dead_callback("_check_parent: The parent is dead.")
report_raylet_error_logs(log_dir, gcs_client)
sys.exit(0)
else:
parent_death_cnt = 0
await asyncio.sleep(
dashboard_consts.DASHBOARD_AGENT_CHECK_PARENT_INTERVAL_S
)
except Exception:
logger.exception("Failed to check parent PID, exiting.")
sys.exit(1)
+238
View File
@@ -0,0 +1,238 @@
import json
import os
from collections import defaultdict
from dataclasses import asdict, dataclass
from typing import Dict, List, Optional, Union
import ray
class _NullLogSpan:
"""A log span context manager that does nothing"""
def __enter__(self):
pass
def __exit__(self, type, value, tb):
pass
PROFILING_ENABLED = "RAY_PROFILING" in os.environ
NULL_LOG_SPAN = _NullLogSpan()
# Colors are specified at
# https://github.com/catapult-project/catapult/blob/master/tracing/tracing/base/color_scheme.html. # noqa: E501
_default_color_mapping = defaultdict(
lambda: "generic_work",
{
"worker_idle": "cq_build_abandoned",
"task": "rail_response",
"task:deserialize_arguments": "rail_load",
"task:execute": "rail_animation",
"task:store_outputs": "rail_idle",
"wait_for_function": "detailed_memory_dump",
"ray.get": "good",
"ray.put": "terrible",
"ray.wait": "vsync_highlight_color",
"submit_task": "background_memory_dump",
"fetch_and_run_function": "detailed_memory_dump",
"register_remote_function": "detailed_memory_dump",
},
)
@dataclass(init=True)
class ChromeTracingCompleteEvent:
# https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.lpfof2aylapb # noqa
# The event categories. This is a comma separated list of categories
# for the event. The categories can be used to hide events in
# the Trace Viewer UI.
cat: str
# The string displayed on the event.
name: str
# The identifier for the group of rows that the event
# appears in.
pid: int
# The identifier for the row that the event appears in.
tid: int
# The start time in microseconds.
ts: int
# The duration in microseconds.
dur: int
# This is the name of the color to display the box in.
cname: str
# The extra user-defined data.
args: Dict[str, Union[str, int]]
# The event type (X means the complete event).
ph: str = "X"
@dataclass(init=True)
class ChromeTracingMetadataEvent:
# https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#bookmark=id.iycbnb4z7i9g # noqa
name: str
# Metadata arguments. E.g., name: <metadata_name>
args: Dict[str, str]
# The process id of this event. In Ray, pid indicates the node.
pid: int
# The thread id of this event. In Ray, tid indicates each worker.
tid: int = None
# M means the metadata event.
ph: str = "M"
def profile(event_type: str, extra_data: Optional[Dict[str, str]] = None):
"""Profile a span of time so that it appears in the timeline visualization.
Note that this only works in the raylet code path.
This function can be used as follows (both on the driver or within a task).
.. testcode::
import ray._private.profiling as profiling
with profiling.profile("custom event", extra_data={'key': 'val'}):
# Do some computation here.
x = 1 * 2
Optionally, a dictionary can be passed as the "extra_data" argument, and
it can have keys "name" and "cname" if you want to override the default
timeline display text and box color. Other values will appear at the bottom
of the chrome tracing GUI when you click on the box corresponding to this
profile span.
Args:
event_type: A string describing the type of the event.
extra_data: This must be a dictionary mapping strings to strings. This
data will be added to the json objects that are used to populate
the timeline, so if you want to set a particular color, you can
simply set the "cname" attribute to an appropriate color.
Similarly, if you set the "name" attribute, then that will set the
text displayed on the box in the timeline.
Returns:
An object that can profile a span of time via a "with" statement.
"""
if not PROFILING_ENABLED:
return NULL_LOG_SPAN
worker = ray._private.worker.global_worker
return worker.core_worker.profile_event(event_type.encode("ascii"), extra_data)
def chrome_tracing_dump(
tasks: List[dict],
) -> str:
"""Generate a chrome/perfetto tracing dump using task events.
Args:
tasks: List of tasks generated by a state API list_tasks(detail=True).
Returns:
Json serialized dump to create a chrome/perfetto tracing.
"""
# All events from given tasks.
all_events = []
# Chrome tracing doesn't have a concept of "node". Instead, we use
# chrome tracing's pid == ray's node.
# chrome tracing's tid == ray's process.
# Note that pid or tid is usually integer, but ray's node/process has
# ids in string.
# Unfortunately, perfetto doesn't allow to have string as a value of pid/tid.
# To workaround it, we use Metadata event from chrome tracing schema
# (https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.xqopa5m0e28f) # noqa
# which allows pid/tid -> name mapping. In order to use this schema
# we build node_ip/(node_ip, worker_id) -> arbitrary index mapping.
# node ip address -> node idx.
node_to_index = {}
# Arbitrary index mapped to the ip address.
node_idx = 0
# (node index, worker id) -> worker idx
worker_to_index = {}
# Arbitrary index mapped to the (node index, worker id).
worker_idx = 0
for task in tasks:
profiling_data = task.get("profiling_data", [])
if profiling_data:
node_ip_address = profiling_data["node_ip_address"]
component_events = profiling_data["events"]
component_type = profiling_data["component_type"]
component_id = component_type + ":" + profiling_data["component_id"]
if component_type not in ["worker", "driver"]:
continue
for event in component_events:
extra_data = event["extra_data"]
# Propagate extra data.
extra_data["task_id"] = task["task_id"]
extra_data["job_id"] = task["job_id"]
extra_data["attempt_number"] = task["attempt_number"]
extra_data["func_or_class_name"] = task["func_or_class_name"]
extra_data["actor_id"] = task["actor_id"]
event_name = event["event_name"]
# build a id -> arbitrary index mapping
if node_ip_address not in node_to_index:
node_to_index[node_ip_address] = node_idx
# Whenever new node ip is introduced, we increment the index.
node_idx += 1
if (
node_to_index[node_ip_address],
component_id,
) not in worker_to_index: # noqa
worker_to_index[
(node_to_index[node_ip_address], component_id)
] = worker_idx # noqa
worker_idx += 1
# Modify the name with the additional user-defined extra data.
cname = _default_color_mapping[event["event_name"]]
name = event_name
if "cname" in extra_data:
cname = _default_color_mapping[event["extra_data"]["cname"]]
if "name" in extra_data:
name = extra_data["name"]
new_event = ChromeTracingCompleteEvent(
cat=event_name,
name=name,
pid=node_to_index[node_ip_address],
tid=worker_to_index[(node_to_index[node_ip_address], component_id)],
ts=event["start_time"] * 1e3,
dur=(event["end_time"] * 1e3) - (event["start_time"] * 1e3),
cname=cname,
args=extra_data,
)
all_events.append(asdict(new_event))
for node, i in node_to_index.items():
all_events.append(
asdict(
ChromeTracingMetadataEvent(
name="process_name",
pid=i,
args={"name": f"Node {node}"},
)
)
)
for worker, i in worker_to_index.items():
all_events.append(
asdict(
ChromeTracingMetadataEvent(
name="thread_name",
ph="M",
tid=i,
pid=worker[0],
args={"name": worker[1]},
)
)
)
# Handle task event disabled.
return json.dumps(all_events)
+375
View File
@@ -0,0 +1,375 @@
# NOTE: This file has been copied from OpenCensus Python exporter.
# It is because OpenCensus Prometheus exporter hasn't released for a while
# and the latest version has a compatibility issue with the latest OpenCensus
# library.
import logging
import re
import threading
from typing import Any
from wsgiref.simple_server import make_server
from opencensus.common.transports import sync
from opencensus.stats import aggregation_data as aggregation_data_module, base_exporter
from prometheus_client import make_wsgi_app
from prometheus_client.core import (
REGISTRY,
CollectorRegistry,
CounterMetricFamily,
GaugeMetricFamily,
HistogramMetricFamily,
UnknownMetricFamily,
)
logger = logging.getLogger(__name__)
class Options(object):
"""Options contains options for configuring the exporter.
The address can be empty as the prometheus client will
assume it's localhost.
Args:
namespace: The prometheus namespace to be used. Defaults to ''.
port: The Prometheus port to be used. Defaults to 8000.
address: The Prometheus address to be used. Defaults to ''.
registry: A Prometheus collector registry instance.
"""
def __init__(
self,
namespace: str = "",
port: int = 8000,
address: str = "",
registry: CollectorRegistry = REGISTRY,
):
self._namespace = namespace
self._registry = registry
self._port = int(port)
self._address = address
@property
def registry(self):
"""Prometheus Collector Registry instance"""
return self._registry
@property
def namespace(self):
"""Prefix to be used with view name"""
return self._namespace
@property
def port(self):
"""Port number to listen"""
return self._port
@property
def address(self):
"""Endpoint address (default is localhost)"""
return self._address
class Collector(object):
"""Collector represents the Prometheus Collector object"""
def __init__(self, options=Options(), view_name_to_data_map=None):
if view_name_to_data_map is None:
view_name_to_data_map = {}
self._options = options
self._registry = options.registry
self._view_name_to_data_map = view_name_to_data_map
self._registered_views = {}
@property
def options(self):
"""Options to be used to configure the exporter"""
return self._options
@property
def registry(self):
"""Prometheus Collector Registry instance"""
return self._registry
@property
def view_name_to_data_map(self):
"""Map with all view data objects
that will be sent to Prometheus
"""
return self._view_name_to_data_map
@property
def registered_views(self):
"""Map with all registered views"""
return self._registered_views
def register_view(self, view):
"""register_view will create the needed structure
in order to be able to sent all data to Prometheus
"""
v_name = get_view_name(self.options.namespace, view)
if v_name not in self.registered_views:
desc = {
"name": v_name,
"documentation": view.description,
"labels": list(map(sanitize, view.columns)),
"units": view.measure.unit,
}
self.registered_views[v_name] = desc
def add_view_data(self, view_data):
"""Add view data object to be sent to server"""
self.register_view(view_data.view)
v_name = get_view_name(self.options.namespace, view_data.view)
self.view_name_to_data_map[v_name] = view_data
# TODO: add start and end timestamp
def to_metric(
self,
desc: dict,
tag_values: tuple,
agg_data: Any,
metrics_map: dict,
) -> None:
"""Translate the data that OpenCensus creates to Prometheus format.
Args:
desc: The map that describes view definition.
tag_values: TagValue object used as label values.
agg_data: Aggregated data that needs to be converted as Prometheus samples.
metrics_map: A map of metric name to Prometheus Metric object that is
populated by this method.
"""
metric_name = desc["name"]
metric_description = desc["documentation"]
label_keys = desc["labels"]
metric_units = desc["units"]
assert len(tag_values) == len(label_keys), (tag_values, label_keys)
# Prometheus requires that all tag values be strings hence
# the need to cast none to the empty string before exporting. See
# https://github.com/census-instrumentation/opencensus-python/issues/480
tag_values = [tv if tv else "" for tv in tag_values]
if isinstance(agg_data, aggregation_data_module.CountAggregationData):
metric = metrics_map.get(metric_name)
if not metric:
metric = CounterMetricFamily(
name=metric_name,
documentation=metric_description,
unit=metric_units,
labels=label_keys,
)
metrics_map[metric_name] = metric
metric.add_metric(labels=tag_values, value=agg_data.count_data)
return
elif isinstance(agg_data, aggregation_data_module.DistributionAggregationData):
assert agg_data.bounds == sorted(agg_data.bounds)
# buckets are a list of buckets. Each bucket is another list with
# a pair of bucket name and value, or a triple of bucket name,
# value, and exemplar. buckets need to be in order.
buckets = []
cum_count = 0 # Prometheus buckets expect cumulative count.
for ii, bound in enumerate(agg_data.bounds):
cum_count += agg_data.counts_per_bucket[ii]
bucket = [str(bound), cum_count]
buckets.append(bucket)
# Prometheus requires buckets to be sorted, and +Inf present.
# In OpenCensus we don't have +Inf in the bucket bonds so need to
# append it here.
buckets.append(["+Inf", agg_data.count_data])
metric = metrics_map.get(metric_name)
if not metric:
metric = HistogramMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics_map[metric_name] = metric
metric.add_metric(
labels=tag_values,
buckets=buckets,
sum_value=agg_data.sum,
)
return
elif isinstance(agg_data, aggregation_data_module.SumAggregationData):
metric = metrics_map.get(metric_name)
if not metric:
metric = UnknownMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics_map[metric_name] = metric
metric.add_metric(labels=tag_values, value=agg_data.sum_data)
return
elif isinstance(agg_data, aggregation_data_module.LastValueAggregationData):
metric = metrics_map.get(metric_name)
if not metric:
metric = GaugeMetricFamily(
name=metric_name,
documentation=metric_description,
labels=label_keys,
)
metrics_map[metric_name] = metric
metric.add_metric(labels=tag_values, value=agg_data.value)
return
else:
raise ValueError(f"unsupported aggregation type {type(agg_data)}")
def collect(self): # pragma: NO COVER
"""Collect fetches the statistics from OpenCensus
and delivers them as Prometheus Metrics.
Collect is invoked every time a prometheus.Gatherer is run
for example when the HTTP endpoint is invoked by Prometheus.
"""
# Make a shallow copy of self._view_name_to_data_map, to avoid seeing
# concurrent modifications when iterating through the dictionary.
metrics_map = {}
for v_name, view_data in self._view_name_to_data_map.copy().items():
if v_name not in self.registered_views:
continue
desc = self.registered_views[v_name]
for tag_values in view_data.tag_value_aggregation_data_map:
agg_data = view_data.tag_value_aggregation_data_map[tag_values]
self.to_metric(desc, tag_values, agg_data, metrics_map)
for metric in metrics_map.values():
yield metric
class PrometheusStatsExporter(base_exporter.StatsExporter):
"""Exporter exports stats to Prometheus.
Users need to register the exporter as an HTTP Handler to be able to export.
Args:
options: An options object with the parameters to instantiate the
prometheus exporter.
gatherer: A Prometheus collector registry instance.
transport: An instance of a Transport to send data with.
collector: An instance of the Prometheus Collector object.
"""
def __init__(
self,
options: Options,
gatherer: CollectorRegistry,
transport: Any = sync.SyncTransport,
collector: "Collector" = Collector(),
):
self._options = options
self._gatherer = gatherer
self._collector = collector
self._transport = transport(self)
self._port = self.serve_http()
REGISTRY.register(self._collector)
@property
def transport(self):
"""The transport way to be sent data to server
(default is sync).
"""
return self._transport
@property
def collector(self):
"""Collector class instance to be used
to communicate with Prometheus
"""
return self._collector
@property
def gatherer(self):
"""Prometheus Collector Registry instance"""
return self._gatherer
@property
def options(self):
"""Options to be used to configure the exporter"""
return self._options
@property
def port(self):
"""The port the HTTP server is listening on."""
return self._port
def export(self, view_data):
"""export send the data to the transport class
in order to be sent to Prometheus in a sync or async way.
"""
if view_data is not None: # pragma: NO COVER
self.transport.export(view_data)
def on_register_view(self, view):
return NotImplementedError("Not supported by Prometheus")
def emit(self, view_data): # pragma: NO COVER
"""Emit exports to the Prometheus if view data has one or more rows.
Each OpenCensus AggregationData will be converted to
corresponding Prometheus Metric: SumData will be converted
to Untyped Metric, CountData will be a Counter Metric
DistributionData will be a Histogram Metric.
"""
for v_data in view_data:
if v_data.tag_value_aggregation_data_map is None:
v_data.tag_value_aggregation_data_map = {}
self.collector.add_view_data(v_data)
def serve_http(self):
"""serve_http serves the Prometheus endpoint."""
address = self.options.address or ""
httpd = make_server(address, self.options.port, make_wsgi_app())
t = threading.Thread(target=httpd.serve_forever)
t.daemon = True
t.start()
# Return the actual port (in case port=0 was specified)
return httpd.server_address[1]
def new_stats_exporter(option):
"""new_stats_exporter returns an exporter
that exports stats to Prometheus.
"""
if option.namespace == "":
raise ValueError("Namespace can not be empty string.")
collector = new_collector(option)
exporter = PrometheusStatsExporter(
options=option, gatherer=option.registry, collector=collector
)
return exporter
def new_collector(options):
"""new_collector should be used
to create instance of Collector class in order to
prevent the usage of constructor directly
"""
return Collector(options=options)
def get_view_name(namespace, view):
"""create the name for the view"""
name = ""
if namespace != "":
name = namespace + "_"
return sanitize(name + view.name)
_NON_LETTERS_NOR_DIGITS_RE = re.compile(r"[^\w]", re.UNICODE | re.IGNORECASE)
def sanitize(key):
"""sanitize the given metric name or label according to Prometheus rule.
Replace all characters other than [A-Za-z0-9_] with '_'.
"""
return _NON_LETTERS_NOR_DIGITS_RE.sub("_", key)
+52
View File
@@ -0,0 +1,52 @@
import inspect
from google.protobuf.json_format import MessageToDict, MessageToJson
"""
This module provides a compatibility layer for different versions of the protobuf
library.
"""
_protobuf_has_old_arg_name_cached = None
def _protobuf_has_old_arg_name():
"""Cache the inspect result to avoid doing it for every single message."""
global _protobuf_has_old_arg_name_cached
if _protobuf_has_old_arg_name_cached is None:
params = inspect.signature(MessageToDict).parameters
_protobuf_has_old_arg_name_cached = "including_default_value_fields" in params
return _protobuf_has_old_arg_name_cached
def rename_always_print_fields_with_no_presence(kwargs):
"""
Protobuf version 5.26.0rc2 renamed argument for `MessageToDict` and `MessageToJson`:
`including_default_value_fields` -> `always_print_fields_with_no_presence`.
See https://github.com/protocolbuffers/protobuf/commit/06e7caba58ede0220b110b89d08f329e5f8a7537#diff-8de817c14d6a087981503c9aea38730b1b3e98f4e306db5ff9d525c7c304f234L129 # noqa: E501
We choose to always use the new argument name. If user used the old arg, we raise an
error.
If protobuf does not have the new arg name but have the old arg name, we rename our
arg to the old one.
"""
old_arg_name = "including_default_value_fields"
new_arg_name = "always_print_fields_with_no_presence"
if old_arg_name in kwargs:
raise ValueError(f"{old_arg_name} is deprecated, please use {new_arg_name}")
if new_arg_name in kwargs and _protobuf_has_old_arg_name():
kwargs[old_arg_name] = kwargs.pop(new_arg_name)
return kwargs
def message_to_dict(*args, **kwargs):
kwargs = rename_always_print_fields_with_no_presence(kwargs)
return MessageToDict(*args, **kwargs)
def message_to_json(*args, **kwargs):
kwargs = rename_always_print_fields_with_no_presence(kwargs)
return MessageToJson(*args, **kwargs)
@@ -0,0 +1,117 @@
import inspect
import logging
import sys
import numpy as np
from ray._private.ray_microbenchmark_helpers import timeit
from ray.util.client.ray_client_helpers import ray_start_client_server
def benchmark_get_calls(ray, results):
value = ray.put(0)
def get_small():
ray.get(value)
results += timeit("client: get calls", get_small)
def benchmark_tasks_and_get_batch(ray, results):
@ray.remote
def small_value():
return b"ok"
def small_value_batch():
submitted = [small_value.remote() for _ in range(1000)]
ray.get(submitted)
return 0
results += timeit("client: tasks and get batch", small_value_batch)
def benchmark_put_calls(ray, results):
def put_small():
ray.put(0)
results += timeit("client: put calls", put_small)
def benchmark_remote_put_calls(ray, results):
@ray.remote
def do_put_small():
for _ in range(100):
ray.put(0)
def put_multi_small():
ray.get([do_put_small.remote() for _ in range(10)])
results += timeit("client: tasks and put batch", put_multi_small, 1000)
def benchmark_put_large(ray, results):
arr = np.zeros(100 * 1024 * 1024, dtype=np.int64)
def put_large():
ray.put(arr)
results += timeit("client: put gigabytes", put_large, 8 * 0.1)
def benchmark_simple_actor(ray, results):
@ray.remote(num_cpus=0)
class Actor:
def small_value(self):
return b"ok"
def small_value_arg(self, x):
return b"ok"
def small_value_batch(self, n):
ray.get([self.small_value.remote() for _ in range(n)])
a = Actor.remote()
def actor_sync():
ray.get(a.small_value.remote())
results += timeit("client: 1:1 actor calls sync", actor_sync)
def actor_async():
ray.get([a.small_value.remote() for _ in range(1000)])
results += timeit("client: 1:1 actor calls async", actor_async, 1000)
a = Actor.options(max_concurrency=16).remote()
def actor_concurrent():
ray.get([a.small_value.remote() for _ in range(1000)])
results += timeit("client: 1:1 actor calls concurrent", actor_concurrent, 1000)
def main(results=None):
results = results or []
ray_config = {"logging_level": logging.WARNING}
def ray_connect_handler(job_config=None, **ray_init_kwargs):
from ray._private.client_mode_hook import disable_client_hook
with disable_client_hook():
import ray as real_ray
if not real_ray.is_initialized():
real_ray.init(**ray_config)
for name, obj in inspect.getmembers(sys.modules[__name__]):
if not name.startswith("benchmark_"):
continue
with ray_start_client_server(ray_connect_handler=ray_connect_handler) as ray:
obj(ray, results)
return results
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
"""This is the script for `ray clusterbenchmark`."""
import time
import numpy as np
import ray
from ray.cluster_utils import Cluster
def main():
cluster = Cluster(
initialize_head=True,
connect=True,
head_node_args={"object_store_memory": 20 * 1024 * 1024 * 1024, "num_cpus": 16},
)
cluster.add_node(
object_store_memory=20 * 1024 * 1024 * 1024, num_gpus=1, num_cpus=16
)
object_ref_list = []
for i in range(0, 10):
object_ref = ray.put(np.random.rand(1024 * 128, 1024))
object_ref_list.append(object_ref)
@ray.remote(num_gpus=1)
def f(object_ref_list):
diffs = []
for object_ref in object_ref_list:
before = time.time()
ray.get(object_ref)
after = time.time()
diffs.append(after - before)
time.sleep(1)
return np.mean(diffs), np.std(diffs)
time_diff, time_diff_std = ray.get(f.remote(object_ref_list))
print(
"latency to get an 1G object over network",
round(time_diff, 2),
"+-",
round(time_diff_std, 2),
)
ray.shutdown()
cluster.shutdown()
if __name__ == "__main__":
main()
+628
View File
@@ -0,0 +1,628 @@
"""Ray constants used in the Python code."""
import json
import logging
import os
import sys
from ray._common.utils import env_bool, env_float, env_integer # noqa: F401
logger = logging.getLogger(__name__)
def env_set_by_user(key):
return key in os.environ
# Whether event logging to driver is enabled. Set to 0 to disable.
AUTOSCALER_EVENTS = env_integer("RAY_SCHEDULER_EVENTS", 1)
# Whether to disable the C++ failure signal handler that provides stack traces
# on crashes. Disabling this is necessary when using Java libraries
# because Ray's signal handler conflicts with the JVM's signal handling.
RAY_DISABLE_FAILURE_SIGNAL_HANDLER = env_bool(
"RAY_DISABLE_FAILURE_SIGNAL_HANDLER", False
)
RAY_LOG_TO_DRIVER = env_bool("RAY_LOG_TO_DRIVER", True)
# Filter level under which events will be filtered out, i.e. not printing to driver
RAY_LOG_TO_DRIVER_EVENT_LEVEL = os.environ.get("RAY_LOG_TO_DRIVER_EVENT_LEVEL", "INFO")
# Internal kv keys for storing monitor debug status.
DEBUG_AUTOSCALING_ERROR = "__autoscaling_error"
DEBUG_AUTOSCALING_STATUS = "__autoscaling_status"
DEBUG_AUTOSCALING_STATUS_LEGACY = "__autoscaling_status_legacy"
ID_SIZE = 28
# The following constants are used to create default values for
# resource isolation when it is enabled.
# TODO(54703): Link to OSS documentation about the feature once it's available.
DEFAULT_CGROUP_PATH = "/sys/fs/cgroup"
# The default proportion of cpu cores to reserve for ray system processes.
DEFAULT_SYSTEM_RESERVED_CPU_PROPORTION = env_float(
"RAY_DEFAULT_SYSTEM_RESERVED_CPU_PROPORTION", 0.05
)
# The default minimum number of cpu cores to reserve for ray system processes.
# This value is used if the available_cores * DEFAULT_SYSTEM_RESERVED_CPU_PROPORTION < this value.
DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES = env_float(
"RAY_DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES", 1.0
)
# The default maximum number of cpu cores to reserve for ray system processes.
# This value is used if the available_cores * DEFAULT_SYSTEM_RESERVED_CPU_PROPORTION > this value.
DEFAULT_MAX_SYSTEM_RESERVED_CPU_CORES = env_float(
"RAY_DEFAULT_MAX_SYSTEM_RESERVED_CPU_CORES", 3.0
)
# The values for SYSTEM_RESERVED_MEMORY do not include the memory reserveed
# for the object store.
# The default proportion available memory to reserve for ray system processes.
DEFAULT_SYSTEM_RESERVED_MEMORY_PROPORTION = env_float(
"RAY_DEFAULT_SYSTEM_RESERVED_MEMORY_PROPORTION", 0.10
)
# The default minimum number of bytes to reserve for ray system processes.
# This value is used if the available_memory * DEFAULT_SYSTEM_RESERVED_MEMORY_PROPORTION < this value.
DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES = env_integer(
"RAY_DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES", 500 * (1024**2) # 500MB
)
# The default maximum number of bytes to reserve for ray system processes.
# This value is used if the available_memory * DEFAULT_SYSTEM_RESERVED_MEMORY_PROPORTION > this value.
DEFAULT_MAX_SYSTEM_RESERVED_MEMORY_BYTES = env_integer(
"RAY_DEFAULT_MAX_SYSTEM_RESERVED_MEMORY_BYTES", (10) * (1024**3)
)
# The default buffer size between the physical memory limit enforced by resource isolation
# and the logical memory limit available for scheduling user tasks. This buffer can be tuned
# to allocate more or less memory room for tolerating passing in the wrong logical memory
# estimate at the cost of lower memory utilization.
DEFAULT_USER_PHYSICAL_LOGICAL_MEMORY_LIMIT_BUFFER_BYTES = env_integer(
"RAY_DEFAULT_USER_PHYSICAL_LOGICAL_MEMORY_LIMIT_BUFFER_BYTES",
500 * (1024**2), # 500MiB
)
# The default maximum number of bytes to allocate to the object store unless
# overridden by the user.
DEFAULT_OBJECT_STORE_MAX_MEMORY_BYTES = env_integer(
"RAY_DEFAULT_OBJECT_STORE_MAX_MEMORY_BYTES", (200) * (10**9) # 200 GB
)
# The default proportion of available memory allocated to the object store
DEFAULT_OBJECT_STORE_MEMORY_PROPORTION = env_float(
"RAY_DEFAULT_OBJECT_STORE_MEMORY_PROPORTION",
0.3,
)
# The smallest cap on the memory used by the object store that we allow.
# This must be greater than MEMORY_RESOURCE_UNIT_BYTES
OBJECT_STORE_MINIMUM_MEMORY_BYTES = 75 * 1024 * 1024
# Each ObjectRef currently uses about 3KB of caller memory.
CALLER_MEMORY_USAGE_PER_OBJECT_REF = 3000
# Above this number of bytes, raise an error by default unless the user sets
# RAY_ALLOW_SLOW_STORAGE=1. This avoids swapping with large object stores.
REQUIRE_SHM_SIZE_THRESHOLD = 10**10
# Mac with 16GB memory has degraded performance when the object store size is
# greater than 2GB.
# (see https://github.com/ray-project/ray/issues/20388 for details)
# The workaround here is to limit capacity to 2GB for Mac by default,
# and raise error if the capacity is overwritten by user.
MAC_DEGRADED_PERF_MMAP_SIZE_LIMIT = (2) * (2**30)
# If a user does not specify a port for the primary Ray service,
# we attempt to start the service running at this port.
DEFAULT_PORT = 6379
RAY_ADDRESS_ENVIRONMENT_VARIABLE = "RAY_ADDRESS"
RAY_API_SERVER_ADDRESS_ENVIRONMENT_VARIABLE = "RAY_API_SERVER_ADDRESS"
RAY_NAMESPACE_ENVIRONMENT_VARIABLE = "RAY_NAMESPACE"
RAY_RUNTIME_ENV_ENVIRONMENT_VARIABLE = "RAY_RUNTIME_ENV"
RAY_RUNTIME_ENV_URI_PIN_EXPIRATION_S_ENV_VAR = (
"RAY_RUNTIME_ENV_TEMPORARY_REFERENCE_EXPIRATION_S"
)
# Ray populates this env var to the working dir in the creation of a runtime env.
# For example, `pip` and `conda` users can use this environment variable to locate the
# `requirements.txt` file.
RAY_RUNTIME_ENV_CREATE_WORKING_DIR_ENV_VAR = "RAY_RUNTIME_ENV_CREATE_WORKING_DIR"
# Defaults to 10 minutes. This should be longer than the total time it takes for
# the local working_dir and py_modules to be uploaded, or these files might get
# garbage collected before the job starts.
RAY_RUNTIME_ENV_URI_PIN_EXPIRATION_S_DEFAULT = 10 * 60
# If set to 1, then `.gitignore` files will not be parsed and loaded into "excludes"
# when using a local working_dir or py_modules.
RAY_RUNTIME_ENV_IGNORE_GITIGNORE = "RAY_RUNTIME_ENV_IGNORE_GITIGNORE"
# Default directories to exclude when packaging working_dir.
# Override by setting the RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES
# (comma-separated) environment variable. Set to an empty string to disable.
# `.git` is necessary since it is never in .gitignore.
RAY_RUNTIME_ENV_DEFAULT_EXCLUDES = ".git,.venv,venv,__pycache__"
def get_runtime_env_default_excludes() -> list[str]:
"""Get default excludes for working_dir, overridable via RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES environment variable."""
val = os.environ.get(
"RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES", RAY_RUNTIME_ENV_DEFAULT_EXCLUDES
)
return [x.strip() for x in val.split(",") if x.strip()]
# Hook for running a user-specified runtime-env hook. This hook will be called
# unconditionally given the runtime_env dict passed for ray.init. It must return
# a rewritten runtime_env dict. Example: "your.module.runtime_env_hook".
RAY_RUNTIME_ENV_HOOK = "RAY_RUNTIME_ENV_HOOK"
# Hook that is invoked on `ray start`. It will be given the cluster parameters and
# whether we are the head node as arguments. The function can modify the params class,
# but otherwise returns void. Example: "your.module.ray_start_hook".
RAY_START_HOOK = "RAY_START_HOOK"
# Hook that is invoked on `ray job submit`. It will be given all the same args as the
# job.cli.submit() function gets, passed as kwargs to this function.
RAY_JOB_SUBMIT_HOOK = "RAY_JOB_SUBMIT_HOOK"
# Headers to pass when using the Job CLI. It will be given to
# instantiate a Job SubmissionClient.
RAY_JOB_HEADERS = "RAY_JOB_HEADERS"
# Timeout waiting for the dashboard to come alive during node startup.
RAY_DASHBOARD_STARTUP_TIMEOUT_S = env_integer("RAY_DASHBOARD_STARTUP_TIMEOUT_S", 60)
# Enable profiling endpoints in the dashboard.
RAY_DASHBOARD_ENABLE_PROFILING = env_bool("RAY_DASHBOARD_ENABLE_PROFILING", False)
DEFAULT_DASHBOARD_PORT = 8265
DASHBOARD_ADDRESS = "dashboard"
DASHBOARD_CLIENT_MAX_SIZE = 100 * 1024**2
PROMETHEUS_SERVICE_DISCOVERY_FILE = "prom_metrics_service_discovery.json"
DEFAULT_DASHBOARD_AGENT_LISTEN_PORT = 52365
# Default resource requirements for actors when no resource requirements are
# specified.
DEFAULT_ACTOR_METHOD_CPU_SIMPLE = 1
DEFAULT_ACTOR_CREATION_CPU_SIMPLE = 0
# Default resource requirements for actors when some resource requirements are
# specified in .
DEFAULT_ACTOR_METHOD_CPU_SPECIFIED = 0
DEFAULT_ACTOR_CREATION_CPU_SPECIFIED = 1
# Default number of return values for each actor method.
DEFAULT_ACTOR_METHOD_NUM_RETURN_VALS = 1
# Wait 30 seconds for client to reconnect after unexpected disconnection
DEFAULT_CLIENT_RECONNECT_GRACE_PERIOD = 30
# If a remote function or actor (or some other export) has serialized size
# greater than this quantity, print an warning.
FUNCTION_SIZE_WARN_THRESHOLD = 10**7
FUNCTION_SIZE_ERROR_THRESHOLD = env_integer("FUNCTION_SIZE_ERROR_THRESHOLD", (10**8))
# If remote functions with the same source are imported this many times, then
# print a warning.
DUPLICATE_REMOTE_FUNCTION_THRESHOLD = 100
# The maximum resource quantity that is allowed. TODO(rkn): This could be
# relaxed, but the current implementation of the node manager will be slower
# for large resource quantities due to bookkeeping of specific resource IDs.
MAX_RESOURCE_QUANTITY = 100e12
# Number of units 1 resource can be subdivided into.
MIN_RESOURCE_GRANULARITY = 0.0001
# Set this environment variable to populate the dashboard URL with
# an external hosted Ray dashboard URL (e.g. because the
# dashboard is behind a proxy or load balancer). This only overrides
# the dashboard URL when returning or printing to a user through a public
# API, but not in the internal KV store.
RAY_OVERRIDE_DASHBOARD_URL = "RAY_OVERRIDE_DASHBOARD_URL"
# Different types of Ray errors that can be pushed to the driver.
# TODO(rkn): These should be defined in flatbuffers and must be synced with
# the existing C++ definitions.
PICKLING_LARGE_OBJECT_PUSH_ERROR = "pickling_large_object"
WAIT_FOR_FUNCTION_PUSH_ERROR = "wait_for_function"
VERSION_MISMATCH_PUSH_ERROR = "version_mismatch"
WORKER_CRASH_PUSH_ERROR = "worker_crash"
WORKER_DIED_PUSH_ERROR = "worker_died"
WORKER_POOL_LARGE_ERROR = "worker_pool_large"
PUT_RECONSTRUCTION_PUSH_ERROR = "put_reconstruction"
RESOURCE_DEADLOCK_ERROR = "resource_deadlock"
REMOVED_NODE_ERROR = "node_removed"
MONITOR_DIED_ERROR = "monitor_died"
LOG_MONITOR_DIED_ERROR = "log_monitor_died"
DASHBOARD_AGENT_DIED_ERROR = "dashboard_agent_died"
DASHBOARD_DIED_ERROR = "dashboard_died"
RAYLET_DIED_ERROR = "raylet_died"
DETACHED_ACTOR_ANONYMOUS_NAMESPACE_ERROR = "detached_actor_anonymous_namespace"
EXCESS_QUEUEING_WARNING = "excess_queueing_warning"
# Used by autoscaler to set the node custom resources and labels
# from cluster.yaml.
RESOURCES_ENVIRONMENT_VARIABLE = "RAY_OVERRIDE_RESOURCES"
LABELS_ENVIRONMENT_VARIABLE = "RAY_OVERRIDE_LABELS"
# Temporary flag to disable log processing in the dashboard. This is useful
# if the dashboard is overloaded by logs and failing to process other
# dashboard API requests (e.g. Job Submission).
DISABLE_DASHBOARD_LOG_INFO = env_integer("RAY_DISABLE_DASHBOARD_LOG_INFO", 0)
LOGGER_FORMAT = "%(asctime)s\t%(levelname)s %(filename)s:%(lineno)s -- %(message)s"
LOGGER_FORMAT_ESCAPE = json.dumps(LOGGER_FORMAT.replace("%", "%%"))
LOGGER_FORMAT_HELP = f"The logging format. default={LOGGER_FORMAT_ESCAPE}"
# Configure the default logging levels for various Ray components.
# TODO (kevin85421): Currently, I don't encourage Ray users to configure
# `RAY_LOGGER_LEVEL` until its scope and expected behavior are clear and
# easy to understand. Now, only Ray developers should use it.
LOGGER_LEVEL = os.environ.get("RAY_LOGGER_LEVEL", "info")
LOGGER_LEVEL_CHOICES = ["debug", "info", "warning", "error", "critical"]
LOGGER_LEVEL_HELP = (
"The logging level threshold, choices=['debug', 'info',"
" 'warning', 'error', 'critical'], default='info'"
)
LOGGING_REDIRECT_STDERR_ENVIRONMENT_VARIABLE = "RAY_LOG_TO_STDERR"
# Logging format when logging stderr. This should be formatted with the
# component before setting the formatter, e.g. via
# format = LOGGER_FORMAT_STDERR.format(component="dashboard")
# handler.setFormatter(logging.Formatter(format))
LOGGER_FORMAT_STDERR = (
"%(asctime)s\t%(levelname)s ({component}) %(filename)s:%(lineno)s -- %(message)s"
)
# Constants used to define the different process types.
PROCESS_TYPE_REAPER = "reaper"
PROCESS_TYPE_MONITOR = "monitor"
PROCESS_TYPE_RAY_CLIENT_SERVER = "ray_client_server"
PROCESS_TYPE_LOG_MONITOR = "log_monitor"
PROCESS_TYPE_DASHBOARD = "dashboard"
PROCESS_TYPE_DASHBOARD_AGENT = "dashboard_agent"
PROCESS_TYPE_RUNTIME_ENV_AGENT = "runtime_env_agent"
PROCESS_TYPE_WORKER = "worker"
PROCESS_TYPE_RAYLET = "raylet"
PROCESS_TYPE_REDIS_SERVER = "redis_server"
PROCESS_TYPE_GCS_SERVER = "gcs_server"
PROCESS_TYPE_PYTHON_CORE_WORKER_DRIVER = "python-core-driver"
PROCESS_TYPE_PYTHON_CORE_WORKER = "python-core-worker"
# Log file names
MONITOR_LOG_FILE_NAME = f"{PROCESS_TYPE_MONITOR}.log"
LOG_MONITOR_LOG_FILE_NAME = f"{PROCESS_TYPE_LOG_MONITOR}.log"
# Enable log deduplication.
RAY_DEDUP_LOGS = env_bool("RAY_DEDUP_LOGS", True)
RAY_FLUSH_DRIVER_LOGS = env_bool("RAY_FLUSH_DRIVER_LOGS", False)
# How many seconds of messages to buffer for log deduplication.
RAY_DEDUP_LOGS_AGG_WINDOW_S = env_integer("RAY_DEDUP_LOGS_AGG_WINDOW_S", 5)
# Regex for log messages to never deduplicate, or None. This takes precedence over
# the skip regex below. A default pattern is set for testing.
TESTING_NEVER_DEDUP_TOKEN = "__ray_testing_never_deduplicate__"
RAY_DEDUP_LOGS_ALLOW_REGEX = os.environ.get(
"RAY_DEDUP_LOGS_ALLOW_REGEX", TESTING_NEVER_DEDUP_TOKEN
)
# Regex for log messages to always skip / suppress, or None.
RAY_DEDUP_LOGS_SKIP_REGEX = os.environ.get("RAY_DEDUP_LOGS_SKIP_REGEX")
AGENT_PROCESS_TYPE_DASHBOARD_AGENT = "ray::DashboardAgent"
AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT = "ray::RuntimeEnvAgent"
AGENT_PROCESS_LIST = [
AGENT_PROCESS_TYPE_DASHBOARD_AGENT,
AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT,
]
WORKER_PROCESS_TYPE_IDLE_WORKER = "ray::IDLE"
WORKER_PROCESS_TYPE_SPILL_WORKER_NAME = "SpillWorker"
WORKER_PROCESS_TYPE_RESTORE_WORKER_NAME = "RestoreWorker"
WORKER_PROCESS_TYPE_SPILL_WORKER_IDLE = (
f"ray::IDLE_{WORKER_PROCESS_TYPE_SPILL_WORKER_NAME}"
)
WORKER_PROCESS_TYPE_RESTORE_WORKER_IDLE = (
f"ray::IDLE_{WORKER_PROCESS_TYPE_RESTORE_WORKER_NAME}"
)
WORKER_PROCESS_TYPE_SPILL_WORKER = f"ray::SPILL_{WORKER_PROCESS_TYPE_SPILL_WORKER_NAME}"
WORKER_PROCESS_TYPE_RESTORE_WORKER = (
f"ray::RESTORE_{WORKER_PROCESS_TYPE_RESTORE_WORKER_NAME}"
)
WORKER_PROCESS_TYPE_SPILL_WORKER_DELETE = (
f"ray::DELETE_{WORKER_PROCESS_TYPE_SPILL_WORKER_NAME}"
)
WORKER_PROCESS_TYPE_RESTORE_WORKER_DELETE = (
f"ray::DELETE_{WORKER_PROCESS_TYPE_RESTORE_WORKER_NAME}"
)
# The number of files the log monitor will open. If more files exist, they will
# be ignored.
LOG_MONITOR_MAX_OPEN_FILES = int(
os.environ.get("RAY_LOG_MONITOR_MAX_OPEN_FILES", "200")
)
# The maximum batch of lines to be read in a single iteration. We _always_ try
# to read this number of lines even if there aren't any new lines.
LOG_MONITOR_NUM_LINES_TO_READ = int(
os.environ.get("RAY_LOG_MONITOR_NUM_LINES_TO_READ", "1000")
)
# Autoscaler events are denoted by the ":event_summary:" magic token.
LOG_PREFIX_EVENT_SUMMARY = ":event_summary:"
# Cluster-level info events are denoted by the ":info_message:" magic token. These may
# be emitted in the stderr of Ray components.
LOG_PREFIX_INFO_MESSAGE = ":info_message:"
# Actor names are recorded in the logs with this magic token as a prefix.
LOG_PREFIX_ACTOR_NAME = ":actor_name:"
# Task names are recorded in the logs with this magic token as a prefix.
LOG_PREFIX_TASK_NAME = ":task_name:"
# Job ids are recorded in the logs with this magic token as a prefix.
LOG_PREFIX_JOB_ID = ":job_id:"
# The object metadata field uses the following format: It is a comma
# separated list of fields. The first field is mandatory and is the
# type of the object (see types below) or an integer, which is interpreted
# as an error value. The second part is optional and if present has the
# form DEBUG:<breakpoint_id>, it is used for implementing the debugger.
# A constant used as object metadata to indicate the object is cross language.
OBJECT_METADATA_TYPE_CROSS_LANGUAGE = b"XLANG"
# A constant used as object metadata to indicate the object is python specific.
OBJECT_METADATA_TYPE_PYTHON = b"PYTHON"
# A constant used as object metadata to indicate the object is raw bytes.
OBJECT_METADATA_TYPE_RAW = b"RAW"
# A constant used as object metadata to indicate the object is an actor handle.
# This value should be synchronized with the Java definition in
# ObjectSerializer.java
# TODO(fyrestone): Serialize the ActorHandle via the custom type feature
# of XLANG.
OBJECT_METADATA_TYPE_ACTOR_HANDLE = b"ACTOR_HANDLE"
# A constant indicating the debugging part of the metadata (see above).
OBJECT_METADATA_DEBUG_PREFIX = b"DEBUG:"
AUTOSCALER_RESOURCE_REQUEST_CHANNEL = b"autoscaler_resource_request"
REDIS_DEFAULT_USERNAME = ""
REDIS_DEFAULT_PASSWORD = ""
# The Mach kernel page size in bytes.
MACH_PAGE_SIZE_BYTES = 4096
# The max number of bytes for task execution error message.
MAX_APPLICATION_ERROR_LENGTH = env_integer("RAY_MAX_APPLICATION_ERROR_LENGTH", 500)
# Max 64 bit integer value, which is needed to ensure against overflow
# in C++ when passing integer values cross-language.
MAX_INT64_VALUE = 9223372036854775807
# Object Spilling related constants
DEFAULT_OBJECT_PREFIX = "ray_spilled_objects"
GCS_PORT_ENVIRONMENT_VARIABLE = "RAY_GCS_SERVER_PORT"
HEALTHCHECK_EXPIRATION_S = os.environ.get("RAY_HEALTHCHECK_EXPIRATION_S", 10)
# Filename of "shim process" that sets up Python worker environment.
# Should be kept in sync with kSetupWorkerFilename in
# src/ray/common/constants.h.
SETUP_WORKER_FILENAME = "setup_worker.py"
# Directory name where runtime_env resources will be created & cached.
DEFAULT_RUNTIME_ENV_DIR_NAME = "runtime_resources"
# The timeout seconds for the creation of runtime env,
# dafault timeout is 10 minutes
DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS = 600
# The timeout seconds for the GCS server request.
# Try fetching from the cpp environment variable first.
GCS_SERVER_REQUEST_TIMEOUT_SECONDS = int(
os.environ.get("RAY_gcs_server_request_timeout_seconds", "60")
)
# Used to separate lines when formatting the call stack where an ObjectRef was
# created.
CALL_STACK_LINE_DELIMITER = " | "
# The default gRPC max message size is 4 MiB, we use a larger number of 512 MiB
# NOTE: This is equal to the C++ limit of (RAY_CONFIG::max_grpc_message_size)
GRPC_CPP_MAX_MESSAGE_SIZE = 512 * 1024 * 1024
# The gRPC send & receive max length for "dashboard agent" server.
# NOTE: This is equal to the C++ limit of RayConfig::max_grpc_message_size
# and HAVE TO STAY IN SYNC with it (ie, meaning that both of these values
# have to be set at the same time)
AGENT_GRPC_MAX_MESSAGE_LENGTH = env_integer(
"AGENT_GRPC_MAX_MESSAGE_LENGTH", 20 * 1024 * 1024 # 20MB
)
# GRPC options
GRPC_ENABLE_HTTP_PROXY = (
1
if os.environ.get("RAY_grpc_enable_http_proxy", "0").lower() in ("1", "true")
else 0
)
GLOBAL_GRPC_OPTIONS = (("grpc.enable_http_proxy", GRPC_ENABLE_HTTP_PROXY),)
# Internal kv namespaces
KV_NAMESPACE_DASHBOARD = b"dashboard"
KV_NAMESPACE_SESSION = b"session"
KV_NAMESPACE_TRACING = b"tracing"
KV_NAMESPACE_PDB = b"ray_pdb"
KV_NAMESPACE_HEALTHCHECK = b"healthcheck"
KV_NAMESPACE_JOB = b"job"
KV_NAMESPACE_CLUSTER = b"cluster"
KV_HEAD_NODE_ID_KEY = b"head_node_id"
# TODO: Set package for runtime env
# We need to update ray client for this since runtime env use ray client
# This might introduce some compatibility issues so leave it here for now.
KV_NAMESPACE_PACKAGE = None
KV_NAMESPACE_FUNCTION_TABLE = b"fun"
LANGUAGE_WORKER_TYPES = ["python", "java", "cpp"]
NEURON_CORES = "neuron_cores"
GPU = "GPU"
TPU = "TPU"
NPU = "NPU"
HPU = "HPU"
RAY_WORKER_NICENESS = "RAY_worker_niceness"
# Default max_retries option in @ray.remote for non-actor
# tasks.
DEFAULT_TASK_MAX_RETRIES = 3
# Default max_concurrency option in @ray.remote for threaded actors.
DEFAULT_MAX_CONCURRENCY_THREADED = 1
# Ray internal flags. These flags should not be set by users, and we strip them on job
# submission.
# This should be consistent with src/ray/common/ray_internal_flag_def.h
RAY_INTERNAL_FLAGS = [
"RAY_JOB_ID",
"RAY_RAYLET_PID",
"RAY_OVERRIDE_NODE_ID_FOR_TESTING",
]
DEFAULT_RESOURCES = {"CPU", "GPU", "memory", "object_store_memory"}
# Supported Python versions for runtime env's "conda" field. Ray downloads
# Ray wheels into the conda environment, so the Ray wheels for these Python
# versions must be available online.
RUNTIME_ENV_CONDA_PY_VERSIONS = [(3, 9), (3, 10), (3, 11), (3, 12)]
# Whether to enable Ray clusters (in addition to local Ray).
# Ray clusters are not explicitly supported for Windows and OSX.
IS_WINDOWS_OR_OSX = sys.platform == "darwin" or sys.platform == "win32"
ENABLE_RAY_CLUSTERS_ENV_VAR = "RAY_ENABLE_WINDOWS_OR_OSX_CLUSTER"
ENABLE_RAY_CLUSTER = env_bool(
ENABLE_RAY_CLUSTERS_ENV_VAR,
not IS_WINDOWS_OR_OSX,
)
SESSION_LATEST = "session_latest"
NUM_PORT_RETRIES = 40
NUM_REDIS_GET_RETRIES = int(os.environ.get("RAY_NUM_REDIS_GET_RETRIES", "20"))
# Turn this on if actor task log's offsets are expected to be recorded.
# With this enabled, actor tasks' log could be queried with task id.
RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING = env_bool(
"RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING", False
)
# RuntimeEnv env var to indicate it exports a function
WORKER_PROCESS_SETUP_HOOK_ENV_VAR = "__RAY_WORKER_PROCESS_SETUP_HOOK_ENV_VAR"
RAY_WORKER_PROCESS_SETUP_HOOK_LOAD_TIMEOUT_ENV_VAR = (
"RAY_WORKER_PROCESS_SETUP_HOOK_LOAD_TIMEOUT" # noqa
)
RAY_DEFAULT_LABEL_KEYS_PREFIX = "ray.io/"
RAY_TPU_MAX_CONCURRENT_CONNECTIONS_ENV_VAR = "RAY_TPU_MAX_CONCURRENT_ACTIVE_CONNECTIONS"
RAY_NODE_IP_FILENAME = "node_ip_address.json"
RAY_LOGGING_CONFIG_ENCODING = os.environ.get("RAY_LOGGING_CONFIG_ENCODING")
RAY_BACKEND_LOG_JSON_ENV_VAR = "RAY_BACKEND_LOG_JSON"
# Write export API event of all resource types to file if enabled.
# RAY_enable_export_api_write_config will not be considered if
# this is enabled.
RAY_ENABLE_EXPORT_API_WRITE = env_bool("RAY_enable_export_api_write", False)
# Comma separated string containing individual resource
# to write export API events for. This configuration is only used if
# RAY_enable_export_api_write is not enabled. Full list of valid
# resource types in ExportEvent.SourceType enum in
# src/ray/protobuf/export_api/export_event.proto
# Example config:
# `export RAY_enable_export_api_write_config='EXPORT_SUBMISSION_JOB,EXPORT_ACTOR'`
RAY_ENABLE_EXPORT_API_WRITE_CONFIG_STR = os.environ.get(
"RAY_enable_export_api_write_config", ""
)
RAY_ENABLE_EXPORT_API_WRITE_CONFIG = RAY_ENABLE_EXPORT_API_WRITE_CONFIG_STR.split(",")
RAY_EXPORT_EVENT_MAX_FILE_SIZE_BYTES = env_bool(
"RAY_EXPORT_EVENT_MAX_FILE_SIZE_BYTES", 100 * 1e6
)
RAY_EXPORT_EVENT_MAX_BACKUP_COUNT = env_bool("RAY_EXPORT_EVENT_MAX_BACKUP_COUNT", 20)
# Comma-separated list of event types that are emitted through the Python
# EventRecorder (One-Event Framework) to the AggregatorAgent.
# Valid values are the names of EventType entries defined in
# src/ray/protobuf/public/events_base_event.proto
# Defaults to PLATFORM_EVENTS if not set.
RAY_ENABLE_PYTHON_RAY_EVENT_TYPES = frozenset(
{
t.strip()
for t in os.environ.get(
"RAY_ENABLE_PYTHON_RAY_EVENT_TYPES", "PLATFORM_EVENT"
).split(",")
if t.strip()
}
)
# If this flag is set and you run the driver with `uv run`, Ray propagates the `uv run`
# environment to all workers. Ray does this by setting the `py_executable` to the
# `uv run`` command line and by propagating the working directory
# via the `working_dir` plugin so uv finds the pyproject.toml.
# If you enable RAY_ENABLE_UV_RUN_RUNTIME_ENV AND you run the driver
# with `uv run`, Ray deactivates the regular RAY_RUNTIME_ENV_HOOK
# because in most cases the hooks wouldn't work unless you specifically make the code
# for the runtime env hook available in your uv environment and make sure your hook
# is compatible with your uv runtime environment. If you want to combine a custom
# RAY_RUNTIME_ENV_HOOK with `uv run`, you should flag off RAY_ENABLE_UV_RUN_RUNTIME_ENV
# and call ray._private.runtime_env.uv_runtime_env_hook.hook manually in your hook or
# manually set the py_executable in your runtime environment hook.
RAY_ENABLE_UV_RUN_RUNTIME_ENV = env_bool("RAY_ENABLE_UV_RUN_RUNTIME_ENV", True)
# Prometheus metric cardinality level setting, either "legacy" or "recommended".
#
# Legacy: report all metrics to prometheus with the set of labels that are reported by
# the component, including WorkerId, (task or actor) Name, etc. This is the default.
# Recommended: report only the node level metrics to prometheus. This means that the
# WorkerId will be removed from all metrics.
# Low: Same as recommended, but also drop the Name label for tasks and actors.
RAY_METRIC_CARDINALITY_LEVEL = os.environ.get(
"RAY_metric_cardinality_level", "recommended"
)
# Whether enable OpenTelemetry as the metrics collection backend. The default is
# using OpenCensus.
RAY_ENABLE_OPEN_TELEMETRY = env_bool("RAY_enable_open_telemetry", True)
# How long to wait for a fetch for an RDT object to complete during ray.get before timing out and raising an exception to the user.
#
# NOTE: This is a tenth of `RayConfig::fetch_fail_timeout_milliseconds` by default as RDT transfers are expected to be much faster.
RDT_FETCH_FAIL_TIMEOUT_SECONDS = (
env_integer("RAY_rdt_fetch_fail_timeout_milliseconds", 60000) / 1000
)
# Whether to enable zero-copy serialization for PyTorch tensors.
# When enabled, Ray serializes PyTorch tensors by converting them to NumPy arrays
# and leveraging pickle5's zero-copy buffer sharing. This avoids copying the
# underlying tensor data, which can improve performance when passing large tensors
# across tasks or actors. Note that this is experimental and should be used with caution
# as we won't copy and allow a write to shared memory. One process changing a tensor
# after ray.get could be reflected in another process.
#
# This feature is experimental and works best under the following conditions:
# - The tensor has `requires_grad=False` (i.e., is detached from the autograd graph).
# - The tensor is contiguous in memory
# - Performance benefits from this are larger if the tensor resides in CPU memory
# - You are not using Ray Direct Transport
#
# Tensors on GPU or non-contiguous tensors are still supported: Ray will
# automatically move them to CPU and/or make them contiguous as needed.
# While this incurs an initial copy, subsequent serialization may still benefit
# from reduced overhead compared to the default path.
#
# Use with caution and ensure tensors meet the above criteria before enabling.
# Default: False.
RAY_ENABLE_ZERO_COPY_TORCH_TENSORS = env_bool(
"RAY_ENABLE_ZERO_COPY_TORCH_TENSORS", False
)
# Max number of cached NIXL remote agents. When exceeded, the least recently used
# remote agent is evicted. When set to 0, there will be no remote agent reuse.
NIXL_REMOTE_AGENT_CACHE_MAXSIZE = env_integer(
"RAY_NIXL_REMOTE_AGENT_CACHE_MAXSIZE", 1000
)
# Name of the environment variable for the Redis password.
RAY_REDIS_PASSWORD_ENV = "RAY_REDIS_PASSWORD"
@@ -0,0 +1,336 @@
"""This is the script for `ray microbenchmark`."""
import asyncio
import logging
import multiprocessing
import ray
import ray.experimental.channel as ray_channel
from ray._common.utils import (
get_or_create_event_loop,
)
from ray._private.ray_microbenchmark_helpers import asyncio_timeit, timeit
from ray._private.test_utils import get_actor_node_id
from ray.dag import InputNode, MultiOutputNode
from ray.dag.compiled_dag_node import CompiledDAG
logger = logging.getLogger(__name__)
@ray.remote
class DAGActor:
def echo(self, x):
return x
def echo_multiple(self, *x):
return x
def check_optimized_build():
if not ray._raylet.OPTIMIZED:
msg = (
"WARNING: Unoptimized build! "
"To benchmark an optimized build, try:\n"
"\tbazel run -c opt //:gen_ray_pkg\n"
"You can also make this permanent by adding\n"
"\tbuild --compilation_mode=opt\n"
"to your user-wide ~/.bazelrc file. "
"(Do not add this to the project-level .bazelrc file.)"
)
logger.warning(msg)
def create_driver_actor():
return CompiledDAG.DAGDriverProxyActor.options(
label_selector={
ray._raylet.RAY_NODE_ID_KEY: ray.get_runtime_context().get_node_id()
}
).remote()
def main(results=None):
results = results or []
loop = get_or_create_event_loop()
check_optimized_build()
print("Tip: set TESTS_TO_RUN='pattern' to run a subset of benchmarks")
#################################################
# Perf tests for channels, used in compiled DAGs.
#################################################
ray.init()
def put_channel_small(chans, do_get=False):
for chan in chans:
chan.write(b"0")
if do_get:
chan.read()
@ray.remote
class ChannelReader:
def ready(self):
return
def read(self, chans):
while True:
for chan in chans:
chan.read()
driver_actor = create_driver_actor()
driver_node = get_actor_node_id(driver_actor)
chans = [ray_channel.Channel(None, [(driver_actor, driver_node)], 1000)]
results += timeit(
"[unstable] local put:local get, single channel calls",
lambda: put_channel_small(chans, do_get=True),
)
reader = ChannelReader.remote()
reader_node = get_actor_node_id(reader)
chans = [ray_channel.Channel(None, [(reader, reader_node)], 1000)]
ray.get(reader.ready.remote())
reader.read.remote(chans)
results += timeit(
"[unstable] local put:1 remote get, single channel calls",
lambda: put_channel_small(chans),
)
ray.kill(reader)
n_cpu = multiprocessing.cpu_count() // 2
print(f"Testing multiple readers/channels, n={n_cpu}")
reader_and_node_list = []
for _ in range(n_cpu):
reader = ChannelReader.remote()
reader_node = get_actor_node_id(reader)
reader_and_node_list.append((reader, reader_node))
chans = [ray_channel.Channel(None, reader_and_node_list, 1000)]
ray.get([reader.ready.remote() for reader, _ in reader_and_node_list])
for reader, _ in reader_and_node_list:
reader.read.remote(chans)
results += timeit(
"[unstable] local put:n remote get, single channel calls",
lambda: put_channel_small(chans),
)
for reader, _ in reader_and_node_list:
ray.kill(reader)
reader = ChannelReader.remote()
reader_node = get_actor_node_id(reader)
chans = [
ray_channel.Channel(None, [(reader, reader_node)], 1000) for _ in range(n_cpu)
]
ray.get(reader.ready.remote())
reader.read.remote(chans)
results += timeit(
"[unstable] local put:1 remote get, n channels calls",
lambda: put_channel_small(chans),
)
ray.kill(reader)
reader_and_node_list = []
for _ in range(n_cpu):
reader = ChannelReader.remote()
reader_node = get_actor_node_id(reader)
reader_and_node_list.append((reader, reader_node))
chans = [
ray_channel.Channel(None, [reader_and_node_list[i]], 1000) for i in range(n_cpu)
]
ray.get([reader.ready.remote() for reader, _ in reader_and_node_list])
for chan, reader_node_tuple in zip(chans, reader_and_node_list):
reader = reader_node_tuple[0]
reader.read.remote([chan])
results += timeit(
"[unstable] local put:n remote get, n channels calls",
lambda: put_channel_small(chans),
)
for reader, _ in reader_and_node_list:
ray.kill(reader)
# Tests for compiled DAGs.
def _exec(dag, num_args=1, payload_size=1):
output_ref = dag.execute(*[b"x" * payload_size for _ in range(num_args)])
ray.get(output_ref)
async def exec_async(tag):
async def _exec_async():
fut = await compiled_dag.execute_async(b"x")
if not isinstance(fut, list):
await fut
else:
await asyncio.gather(*fut)
return await asyncio_timeit(
tag,
_exec_async,
)
# Single-actor DAG calls
a = DAGActor.remote()
with InputNode() as inp:
dag = a.echo.bind(inp)
results += timeit(
"[unstable] single-actor DAG calls", lambda: ray.get(dag.execute(b"x"))
)
compiled_dag = dag.experimental_compile()
results += timeit(
"[unstable] compiled single-actor DAG calls", lambda: _exec(compiled_dag)
)
del a
# Single-actor asyncio DAG calls
a = DAGActor.remote()
with InputNode() as inp:
dag = a.echo.bind(inp)
compiled_dag = dag.experimental_compile(enable_asyncio=True)
results += loop.run_until_complete(
exec_async(
"[unstable] compiled single-actor asyncio DAG calls",
)
)
del a
# Scatter-gather DAG calls
n_cpu = multiprocessing.cpu_count() // 2
actors = [DAGActor.remote() for _ in range(n_cpu)]
with InputNode() as inp:
dag = MultiOutputNode([a.echo.bind(inp) for a in actors])
results += timeit(
f"[unstable] scatter-gather DAG calls, n={n_cpu} actors",
lambda: ray.get(dag.execute(b"x")),
)
compiled_dag = dag.experimental_compile()
results += timeit(
f"[unstable] compiled scatter-gather DAG calls, n={n_cpu} actors",
lambda: _exec(compiled_dag),
)
# Scatter-gather asyncio DAG calls
actors = [DAGActor.remote() for _ in range(n_cpu)]
with InputNode() as inp:
dag = MultiOutputNode([a.echo.bind(inp) for a in actors])
compiled_dag = dag.experimental_compile(enable_asyncio=True)
results += loop.run_until_complete(
exec_async(
f"[unstable] compiled scatter-gather asyncio DAG calls, n={n_cpu} actors",
)
)
# Chain DAG calls
actors = [DAGActor.remote() for _ in range(n_cpu)]
with InputNode() as inp:
dag = inp
for a in actors:
dag = a.echo.bind(dag)
results += timeit(
f"[unstable] chain DAG calls, n={n_cpu} actors",
lambda: ray.get(dag.execute(b"x")),
)
compiled_dag = dag.experimental_compile()
results += timeit(
f"[unstable] compiled chain DAG calls, n={n_cpu} actors",
lambda: _exec(compiled_dag),
)
# Chain asyncio DAG calls
actors = [DAGActor.remote() for _ in range(n_cpu)]
with InputNode() as inp:
dag = inp
for a in actors:
dag = a.echo.bind(dag)
compiled_dag = dag.experimental_compile(enable_asyncio=True)
results += loop.run_until_complete(
exec_async(f"[unstable] compiled chain asyncio DAG calls, n={n_cpu} actors")
)
# Multiple args with small payloads
n_actors = 8
assert (
n_cpu > n_actors
), f"n_cpu ({n_cpu}) must be greater than n_actors ({n_actors})"
actors = [DAGActor.remote() for _ in range(n_actors)]
with InputNode() as inp:
dag = MultiOutputNode([actors[i].echo.bind(inp[i]) for i in range(n_actors)])
payload_size = 1
results += timeit(
f"[unstable] multiple args with small payloads DAG calls, n={n_actors} actors",
lambda: ray.get(dag.execute(*[b"x" * payload_size for _ in range(n_actors)])),
)
compiled_dag = dag.experimental_compile()
results += timeit(
f"[unstable] compiled multiple args with small payloads DAG calls, "
f"n={n_actors} actors",
lambda: _exec(compiled_dag, num_args=n_actors, payload_size=payload_size),
)
# Multiple args with medium payloads
actors = [DAGActor.remote() for _ in range(n_actors)]
with InputNode() as inp:
dag = MultiOutputNode([actors[i].echo.bind(inp[i]) for i in range(n_actors)])
payload_size = 1024 * 1024
results += timeit(
f"[unstable] multiple args with medium payloads DAG calls, n={n_actors} actors",
lambda: ray.get(dag.execute(*[b"x" * payload_size for _ in range(n_actors)])),
)
compiled_dag = dag.experimental_compile()
results += timeit(
"[unstable] compiled multiple args with medium payloads DAG calls, "
f"n={n_actors} actors",
lambda: _exec(compiled_dag, num_args=n_actors, payload_size=payload_size),
)
# Multiple args with large payloads
actors = [DAGActor.remote() for _ in range(n_actors)]
with InputNode() as inp:
dag = MultiOutputNode([actors[i].echo.bind(inp[i]) for i in range(n_actors)])
payload_size = 10 * 1024 * 1024
results += timeit(
f"[unstable] multiple args with large payloads DAG calls, n={n_actors} actors",
lambda: ray.get(dag.execute(*[b"x" * payload_size for _ in range(n_actors)])),
)
compiled_dag = dag.experimental_compile()
results += timeit(
"[unstable] compiled multiple args with large payloads DAG calls, "
f"n={n_actors} actors",
lambda: _exec(compiled_dag, num_args=n_actors, payload_size=payload_size),
)
# Worst case for multiple arguments: a single actor takes all the arguments
# with small payloads.
actor = DAGActor.remote()
n_args = 8
with InputNode() as inp:
dag = actor.echo_multiple.bind(*[inp[i] for i in range(n_args)])
payload_size = 1
results += timeit(
"[unstable] single-actor with all args with small payloads DAG calls, "
"n=1 actors",
lambda: ray.get(dag.execute(*[b"x" * payload_size for _ in range(n_args)])),
)
compiled_dag = dag.experimental_compile()
results += timeit(
"[unstable] single-actor with all args with small payloads DAG calls, "
"n=1 actors",
lambda: _exec(compiled_dag, num_args=n_args, payload_size=payload_size),
)
ray.shutdown()
return results
if __name__ == "__main__":
main()
+380
View File
@@ -0,0 +1,380 @@
import logging
import logging.handlers
import os
import re
import sys
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union
import colorama
import ray
from ray._private.ray_constants import (
RAY_DEDUP_LOGS,
RAY_DEDUP_LOGS_AGG_WINDOW_S,
RAY_DEDUP_LOGS_ALLOW_REGEX,
RAY_DEDUP_LOGS_SKIP_REGEX,
)
from ray.experimental.tqdm_ray import RAY_TQDM_MAGIC
from ray.util.debug import log_once
def setup_logger(
logging_level: int,
logging_format: str,
):
"""Setup default logging for ray."""
logger = logging.getLogger("ray")
if logging_format:
# Overwrite the formatters for all default handlers.
formatter = logging.Formatter(logging_format)
for handler in logger.handlers:
handler.setFormatter(formatter)
if isinstance(logging_level, str):
logging_level = logging.getLevelName(logging_level.upper())
logger.setLevel(logging_level)
def setup_component_logger(
*,
logging_level: Union[int, str],
logging_format: str,
log_dir: str,
filename: Union[str, Iterable[str]],
max_bytes: int,
backup_count: int,
logger_name: Optional[str] = None,
propagate: bool = True,
):
"""Configure the logger that is used for Ray's python components.
For example, it should be used for monitor, dashboard, and log monitor.
The only exception is workers. They use the different logging config.
Ray's python components generally should not write to stdout/stderr, because
messages written there will be redirected to the head node. For deployments where
there may be thousands of workers, this would create unacceptable levels of log
spam. For this reason, we disable the "ray" logger's handlers, and enable
propagation so that log messages that actually do need to be sent to the head node
can reach it.
Args:
logging_level: Logging level in string or logging enum.
logging_format: Logging format string.
log_dir: Log directory path. If empty, logs will go to
stderr.
filename: A single filename or an iterable of filenames to write logs to.
If empty, logs will go to stderr.
max_bytes: Same argument as RotatingFileHandler's maxBytes.
backup_count: Same argument as RotatingFileHandler's backupCount.
logger_name: Used to create or get the corresponding
logger in getLogger call. It will get the root logger by default.
propagate: Whether to propagate the log to the parent logger.
Returns:
the created or modified logger.
"""
ray._private.log.clear_logger("ray")
logger = logging.getLogger(logger_name)
if isinstance(logging_level, str):
logging_level = logging.getLevelName(logging_level.upper())
logger.setLevel(logging_level)
filenames = [filename] if isinstance(filename, str) else filename
for filename in filenames:
if not filename or not log_dir:
handler = logging.StreamHandler()
else:
handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, filename),
maxBytes=max_bytes,
backupCount=backup_count,
)
handler.setLevel(logging_level)
handler.setFormatter(logging.Formatter(logging_format))
logger.addHandler(handler)
logger.propagate = propagate
return logger
def run_callback_on_events_in_ipython(event: str, cb: Callable):
"""
Register a callback to be run after each cell completes in IPython.
E.g.:
This is used to flush the logs after each cell completes.
If IPython is not installed, this function does nothing.
Args:
event: The IPython event to subscribe to (e.g. ``post_run_cell``).
cb: The callback to run.
"""
if "IPython" in sys.modules:
from IPython import get_ipython
ipython = get_ipython()
# Register a callback on cell completion.
if ipython is not None:
ipython.events.register(event, cb)
"""
All components underneath here is used specifically for the default_worker.py.
"""
# It's worth noticing that filepath format should be kept in sync with function
# `GetWorkerOutputFilepath` under file "src/ray/core_worker/core_worker_process.cc".
def get_worker_log_file_name(worker_type, job_id=None):
if job_id is None:
job_id = os.environ.get("RAY_JOB_ID")
if worker_type == "WORKER":
if job_id is None:
job_id = ""
worker_name = "worker"
else:
job_id = ""
worker_name = "io_worker"
# Make sure these values are set already.
assert ray._private.worker._global_node is not None
assert ray._private.worker.global_worker is not None
filename = f"{worker_name}-{ray.get_runtime_context().get_worker_id()}-"
if job_id:
filename += f"{job_id}-"
filename += f"{os.getpid()}"
return filename
def configure_log_file(out_file, err_file):
# If either of the file handles are None, there are no log files to
# configure since we're redirecting all output to stdout and stderr.
if out_file is None or err_file is None:
return
stdout_fileno = sys.stdout.fileno()
stderr_fileno = sys.stderr.fileno()
# C++ logging requires redirecting the stdout file descriptor. Note that
# dup2 will automatically close the old file descriptor before overriding
# it.
os.dup2(out_file.fileno(), stdout_fileno)
os.dup2(err_file.fileno(), stderr_fileno)
# We also manually set sys.stdout and sys.stderr because that seems to
# have an effect on the output buffering. Without doing this, stdout
# and stderr are heavily buffered resulting in seemingly lost logging
# statements. We never want to close the stdout file descriptor, dup2 will
# close it when necessary and we don't want python's GC to close it.
sys.stdout = ray._private.utils.open_log(
stdout_fileno, unbuffered=True, closefd=False
)
sys.stderr = ray._private.utils.open_log(
stderr_fileno, unbuffered=True, closefd=False
)
class WorkerStandardStreamDispatcher:
def __init__(self):
self.handlers = []
self._lock = threading.Lock()
def add_handler(self, name: str, handler: Callable) -> None:
with self._lock:
self.handlers.append((name, handler))
def remove_handler(self, name: str) -> None:
with self._lock:
new_handlers = [pair for pair in self.handlers if pair[0] != name]
self.handlers = new_handlers
def emit(self, data):
with self._lock:
for pair in self.handlers:
_, handle = pair
handle(data)
global_worker_stdstream_dispatcher = WorkerStandardStreamDispatcher()
# Regex for canonicalizing log lines.
NUMBERS = re.compile(r"(\d+|0x[0-9a-fA-F]+)")
# Batch of log lines including ip, pid, lines, etc.
LogBatch = Dict[str, Any]
def _canonicalise_log_line(line):
# Remove words containing numbers or hex, since those tend to differ between
# workers.
return " ".join(x for x in line.split() if not NUMBERS.search(x))
@dataclass
class DedupState:
# Timestamp of the earliest log message seen of this pattern.
timestamp: int
# The number of un-printed occurrences for this pattern.
count: int
# Latest instance of this log pattern.
line: int
# Latest metadata dict for this log pattern, not including the lines field.
metadata: LogBatch
# Set of (ip, pid) sources which have emitted this pattern.
sources: Set[Tuple[str, int]]
# The string that should be printed to stdout.
def formatted(self) -> str:
return self.line + _color(
f" [repeated {self.count}x across cluster]" + _warn_once()
)
class LogDeduplicator:
def __init__(
self,
agg_window_s: int,
allow_re: Optional[str],
skip_re: Optional[str],
*,
_timesource=None,
):
self.agg_window_s = agg_window_s
if allow_re:
self.allow_re = re.compile(allow_re)
else:
self.allow_re = None
if skip_re:
self.skip_re = re.compile(skip_re)
else:
self.skip_re = None
# Buffer of up to RAY_DEDUP_LOGS_AGG_WINDOW_S recent log patterns.
# This buffer is cleared if the pattern isn't seen within the window.
self.recent: Dict[str, DedupState] = {}
self.timesource = _timesource or (lambda: time.time())
run_callback_on_events_in_ipython("post_execute", self.flush)
def deduplicate(self, batch: LogBatch) -> List[LogBatch]:
"""Rewrite a batch of lines to reduce duplicate log messages.
Args:
batch: The batch of lines from a single source.
Returns:
List of batches from this and possibly other previous sources to print.
"""
if not RAY_DEDUP_LOGS:
return [batch]
now = self.timesource()
metadata = batch.copy()
del metadata["lines"]
source = (metadata.get("ip"), metadata.get("pid"))
output: List[LogBatch] = [dict(**metadata, lines=[])]
# Decide which lines to emit from the input batch. Put the outputs in the
# first output log batch (output[0]).
for line in batch["lines"]:
if RAY_TQDM_MAGIC in line or (self.allow_re and self.allow_re.search(line)):
output[0]["lines"].append(line)
continue
elif self.skip_re and self.skip_re.search(line):
continue
dedup_key = _canonicalise_log_line(line)
if dedup_key == "":
# Don't dedup messages that are empty after canonicalization.
# Because that's all the information users want to see.
output[0]["lines"].append(line)
continue
if dedup_key in self.recent:
sources = self.recent[dedup_key].sources
sources.add(source)
# We deduplicate the warnings/error messages from raylet by default.
if len(sources) > 1 or batch["pid"] == "raylet":
state = self.recent[dedup_key]
self.recent[dedup_key] = DedupState(
state.timestamp,
state.count + 1,
line,
metadata,
sources,
)
else:
# Don't dedup messages from the same source, just print.
output[0]["lines"].append(line)
else:
self.recent[dedup_key] = DedupState(now, 0, line, metadata, {source})
output[0]["lines"].append(line)
# Flush patterns from the buffer that are older than the aggregation window.
while self.recent:
if now - next(iter(self.recent.values())).timestamp < self.agg_window_s:
break
dedup_key = next(iter(self.recent))
state = self.recent.pop(dedup_key)
# we already logged an instance of this line immediately when received,
# so don't log for count == 0
if state.count > 1:
# (Actor pid=xxxx) [repeated 2x across cluster] ...
output.append(dict(**state.metadata, lines=[state.formatted()]))
# Continue aggregating for this key but reset timestamp and count.
state.timestamp = now
state.count = 0
self.recent[dedup_key] = state
elif state.count > 0:
# Aggregation wasn't fruitful, print the line and stop aggregating.
output.append(dict(state.metadata, lines=[state.line]))
return output
def flush(self) -> List[dict]:
"""Return all buffered log messages and clear the buffer.
Returns:
List of log batches to print.
"""
output = []
for state in self.recent.values():
if state.count > 1:
output.append(
dict(
state.metadata,
lines=[state.formatted()],
)
)
elif state.count > 0:
output.append(dict(state.metadata, **{"lines": [state.line]}))
self.recent.clear()
return output
def _warn_once() -> str:
if log_once("log_dedup_warning"):
return (
" (Ray deduplicates logs by default. Set RAY_DEDUP_LOGS=0 to "
"disable log deduplication, or see https://docs.ray.io/en/master/"
"ray-observability/user-guides/configure-logging.html#log-deduplication "
"for more options.)"
)
else:
return ""
def _color(msg: str) -> str:
return "{}{}{}".format(colorama.Fore.GREEN, msg, colorama.Style.RESET_ALL)
stdout_deduplicator = LogDeduplicator(
RAY_DEDUP_LOGS_AGG_WINDOW_S, RAY_DEDUP_LOGS_ALLOW_REGEX, RAY_DEDUP_LOGS_SKIP_REGEX
)
stderr_deduplicator = LogDeduplicator(
RAY_DEDUP_LOGS_AGG_WINDOW_S, RAY_DEDUP_LOGS_ALLOW_REGEX, RAY_DEDUP_LOGS_SKIP_REGEX
)
@@ -0,0 +1,4 @@
def get_logging_configurator():
from ray._private.ray_logging.logging_config import DefaultLoggingConfigurator
return DefaultLoggingConfigurator()
@@ -0,0 +1,171 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field, fields
from typing import Dict, Set
from ray._common.filters import CoreContextFilter
from ray._common.formatters import JSONFormatter, TextFormatter
from ray._common.logging_constants import LOGRECORD_STANDARD_ATTRS
from ray._private.ray_logging import default_impl
from ray.util.annotations import PublicAPI
class LoggingConfigurator(ABC):
@abstractmethod
def get_supported_encodings(self) -> Set[str]:
raise NotImplementedError
@abstractmethod
def configure(self, logging_config: "LoggingConfig"):
raise NotImplementedError
class DefaultLoggingConfigurator(LoggingConfigurator):
def __init__(self):
self._encoding_to_formatter = {
"TEXT": TextFormatter(),
"JSON": JSONFormatter(),
}
def get_supported_encodings(self) -> Set[str]:
return self._encoding_to_formatter.keys()
def configure(self, logging_config: "LoggingConfig"):
formatter = self._encoding_to_formatter[logging_config.encoding]
formatter.set_additional_log_standard_attrs(
logging_config.additional_log_standard_attrs
)
core_context_filter = CoreContextFilter()
handler = logging.StreamHandler()
handler.setLevel(logging_config.log_level)
handler.setFormatter(formatter)
handler.addFilter(core_context_filter)
root_logger = logging.getLogger()
root_logger.setLevel(logging_config.log_level)
root_logger.addHandler(handler)
ray_logger = logging.getLogger("ray")
ray_logger.setLevel(logging_config.log_level)
# Remove all existing handlers added by `ray/__init__.py`.
for h in ray_logger.handlers[:]:
ray_logger.removeHandler(h)
ray_logger.addHandler(handler)
ray_logger.propagate = False
_logging_configurator: LoggingConfigurator = default_impl.get_logging_configurator()
# Class defines the logging configurations for a Ray job.
# To add a new logging configuration: (1) add a new field to this class; (2) Update the
# logic in the __post_init__ method in this class to add the validation logic;
# (3) Update the configure method in the DefaultLoggingConfigurator
# class to use the new field.
@PublicAPI(stability="alpha")
@dataclass
class LoggingConfig:
encoding: str = "TEXT"
log_level: str = "INFO"
# The list of valid attributes are defined as LOGRECORD_STANDARD_ATTRS in
# constants.py.
additional_log_standard_attrs: list = field(default_factory=list)
def __post_init__(self):
if self.encoding not in _logging_configurator.get_supported_encodings():
raise ValueError(
f"Invalid encoding type: {self.encoding}. "
"Valid encoding types are: "
f"{list(_logging_configurator.get_supported_encodings())}"
)
for attr in self.additional_log_standard_attrs:
if attr not in LOGRECORD_STANDARD_ATTRS:
raise ValueError(
f"Unknown python logging standard attribute: {attr}. "
"The valid attributes are: "
f"{set(LOGRECORD_STANDARD_ATTRS)}"
)
def to_dict(self) -> Dict[str, object]:
"""Serialize to a plain dict suitable for JSON transport."""
return asdict(self)
@classmethod
def from_dict(cls, d: Dict[str, object]) -> "LoggingConfig":
"""Create a LoggingConfig from a dict, ignoring unknown keys."""
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in d.items() if k in known})
def _configure_logging(self):
"""Set up the logging configuration for the current process."""
_logging_configurator.configure(self)
def _apply(self):
"""Set up the logging configuration."""
self._configure_logging()
LoggingConfig.__doc__ = """
Logging configuration for a Ray job. These configurations are used to set up the
root logger of the driver process and all Ray tasks and actor processes that belong
to the job.
Examples: 1. Configure the logging to use TEXT encoding.
.. testcode::
import ray
import logging
ray.init(
logging_config=ray.LoggingConfig(encoding="TEXT", log_level="INFO", additional_log_standard_attrs=['name'])
)
@ray.remote
def f():
logger = logging.getLogger(__name__)
logger.info("This is a Ray task")
ray.get(f.remote())
ray.shutdown()
.. testoutput::
:options: +MOCK
2025-02-12 12:25:16,836 INFO test-log-config.py:11 -- This is a Ray task name=__main__ job_id=01000000 worker_id=51188d9448be4664bf2ea26ac410b67acaaa970c4f31c5ad3ae776a5 node_id=f683dfbffe2c69984859bc19c26b77eaf3866c458884c49d115fdcd4 task_id=c8ef45ccd0112571ffffffffffffffffffffffff01000000 task_name=f task_func_name=test-log-config.f timestamp_ns=1739391916836884000
2. Configure the logging to use JSON encoding.
.. testcode::
import ray
import logging
ray.init(
logging_config=ray.LoggingConfig(encoding="JSON", log_level="INFO", additional_log_standard_attrs=['name'])
)
@ray.remote
def f():
logger = logging.getLogger(__name__)
logger.info("This is a Ray task")
ray.get(f.remote())
ray.shutdown()
.. testoutput::
:options: +MOCK
{"asctime": "2025-02-12 12:25:48,766", "levelname": "INFO", "message": "This is a Ray task", "filename": "test-log-config.py", "lineno": 11, "name": "__main__", "job_id": "01000000", "worker_id": "6d307578014873fcdada0fa22ea6d49e0fb1f78960e69d61dfe41f5a", "node_id": "69e3a5e68bdc7eb8ac9abb3155326ee3cc9fc63ea1be04d11c0d93c7", "task_id": "c8ef45ccd0112571ffffffffffffffffffffffff01000000", "task_name": "f", "task_func_name": "test-log-config.f", "timestamp_ns": 1739391948766949000}
Args:
encoding: Encoding type for the logs. The valid values are
{list(_logging_configurator.get_supported_encodings())}
log_level: Log level for the logs. Defaults to 'INFO'. You can set
it to 'DEBUG' to receive more detailed debug logs.
additional_log_standard_attrs: List of additional standard python logger attributes to
include in the log. Defaults to an empty list. The list of already
included standard attributes are: "asctime", "levelname", "message",
"filename", "lineno", "exc_text". The list of valid attributes are specified
here: http://docs.python.org/library/logging.html#logrecord-attributes
""" # noqa: E501
@@ -0,0 +1,92 @@
import os
import time
from contextlib import contextmanager
from typing import List, Optional, Tuple
import numpy as np
import ray
# Only run tests matching this filter pattern.
filter_pattern = os.environ.get("TESTS_TO_RUN", "")
skip_pattern = os.environ.get("TESTS_TO_SKIP", "")
def timeit(
name, fn, multiplier=1, warmup_time_sec=10
) -> List[Optional[Tuple[str, float, float]]]:
if filter_pattern and filter_pattern not in name:
return [None]
if skip_pattern and skip_pattern in name:
return [None]
# sleep for a while to avoid noisy neigbhors.
# related issue: https://github.com/ray-project/ray/issues/22045
time.sleep(warmup_time_sec)
# warmup
start = time.perf_counter()
count = 0
while time.perf_counter() - start < 1:
fn()
count += 1
# real run
step = count // 10 + 1
stats = []
for _ in range(4):
start = time.perf_counter()
count = 0
while time.perf_counter() - start < 2:
for _ in range(step):
fn()
count += step
end = time.perf_counter()
stats.append(multiplier * count / (end - start))
mean = np.mean(stats)
sd = np.std(stats)
print(name, "per second", round(mean, 2), "+-", round(sd, 2))
return [(name, mean, sd)]
async def asyncio_timeit(
name, async_fn, multiplier=1, warmup_time_sec=10
) -> List[Optional[Tuple[str, float, float]]]:
if filter_pattern and filter_pattern not in name:
return [None]
if skip_pattern and skip_pattern in name:
return [None]
# sleep for a while to avoid noisy neigbhors.
# related issue: https://github.com/ray-project/ray/issues/22045
time.sleep(warmup_time_sec)
# warmup
start = time.perf_counter()
count = 0
while time.perf_counter() - start < 1:
await async_fn()
count += 1
# real run
step = count // 10 + 1
stats = []
for _ in range(4):
start = time.perf_counter()
count = 0
while time.perf_counter() - start < 2:
for _ in range(step):
await async_fn()
count += step
end = time.perf_counter()
stats.append(multiplier * count / (end - start))
mean = np.mean(stats)
sd = np.std(stats)
print(name, "per second", round(mean, 2), "+-", round(sd, 2))
return [(name, mean, sd)]
@contextmanager
def ray_setup_and_teardown(**init_args):
ray.init(**init_args)
try:
yield None
finally:
ray.shutdown()
+330
View File
@@ -0,0 +1,330 @@
"""This is the script for `ray microbenchmark`."""
import asyncio
import logging
import multiprocessing
import numpy as np
import ray
from ray._private.ray_client_microbenchmark import main as client_microbenchmark_main
from ray._private.ray_microbenchmark_helpers import timeit
logger = logging.getLogger(__name__)
@ray.remote(num_cpus=0)
class Actor:
def small_value(self):
return b"ok"
def small_value_arg(self, x):
return b"ok"
def small_value_batch(self, n):
ray.get([small_value.remote() for _ in range(n)])
@ray.remote
class AsyncActor:
async def small_value(self):
return b"ok"
async def small_value_with_arg(self, x):
return b"ok"
async def small_value_batch(self, n):
await asyncio.wait([small_value.remote() for _ in range(n)])
@ray.remote(num_cpus=0)
class Client:
def __init__(self, servers):
if not isinstance(servers, list):
servers = [servers]
self.servers = servers
def small_value_batch(self, n):
results = []
for s in self.servers:
results.extend([s.small_value.remote() for _ in range(n)])
ray.get(results)
def small_value_batch_arg(self, n):
x = ray.put(0)
results = []
for s in self.servers:
results.extend([s.small_value_arg.remote(x) for _ in range(n)])
ray.get(results)
@ray.remote
def small_value():
return b"ok"
@ray.remote
def small_value_batch(n):
submitted = [small_value.remote() for _ in range(n)]
ray.get(submitted)
return 0
@ray.remote
def create_object_containing_ref():
obj_refs = []
for _ in range(10000):
obj_refs.append(ray.put(1))
return obj_refs
def check_optimized_build():
if not ray._raylet.OPTIMIZED:
msg = (
"WARNING: Unoptimized build! "
"To benchmark an optimized build, try:\n"
"\tbazel run -c opt //:gen_ray_pkg\n"
"You can also make this permanent by adding\n"
"\tbuild --compilation_mode=opt\n"
"to your user-wide ~/.bazelrc file. "
"(Do not add this to the project-level .bazelrc file.)"
)
logger.warning(msg)
def main(results=None):
results = results or []
check_optimized_build()
print("Tip: set TESTS_TO_RUN='pattern' to run a subset of benchmarks")
ray.init()
value = ray.put(0)
def get_small():
ray.get(value)
def put_small():
ray.put(0)
@ray.remote
def do_put_small():
for _ in range(100):
ray.put(0)
def put_multi_small():
ray.get([do_put_small.remote() for _ in range(10)])
arr = np.zeros(100 * 1024 * 1024, dtype=np.int64)
results += timeit("single client get calls (Plasma Store)", get_small)
results += timeit("single client put calls (Plasma Store)", put_small)
results += timeit("multi client put calls (Plasma Store)", put_multi_small, 1000)
def put_large():
ray.put(arr)
results += timeit("single client put gigabytes", put_large, 8 * 0.1)
def small_value_batch():
submitted = [small_value.remote() for _ in range(1000)]
ray.get(submitted)
return 0
results += timeit("single client tasks and get batch", small_value_batch)
@ray.remote
def do_put():
for _ in range(10):
ray.put(np.zeros(10 * 1024 * 1024, dtype=np.int64))
def put_multi():
ray.get([do_put.remote() for _ in range(10)])
results += timeit("multi client put gigabytes", put_multi, 10 * 8 * 0.1)
obj_containing_ref = create_object_containing_ref.remote()
def get_containing_object_ref():
ray.get(obj_containing_ref)
results += timeit(
"single client get object containing 10k refs", get_containing_object_ref
)
def wait_multiple_refs():
num_objs = 1000
not_ready = [small_value.remote() for _ in range(num_objs)]
# We only need to trigger the fetch_local once for each object,
# raylet will persist these fetch requests even after ray.wait returns.
# See https://github.com/ray-project/ray/issues/30375.
fetch_local = True
for _ in range(num_objs):
_ready, not_ready = ray.wait(not_ready, fetch_local=fetch_local)
if fetch_local:
fetch_local = False
results += timeit("single client wait 1k refs", wait_multiple_refs)
def small_task():
ray.get(small_value.remote())
results += timeit("single client tasks sync", small_task)
def small_task_async():
ray.get([small_value.remote() for _ in range(1000)])
results += timeit("single client tasks async", small_task_async, 1000)
n = 10000
m = 4
actors = [Actor.remote() for _ in range(m)]
def multi_task():
submitted = [a.small_value_batch.remote(n) for a in actors]
ray.get(submitted)
results += timeit("multi client tasks async", multi_task, n * m)
a = Actor.remote()
def actor_sync():
ray.get(a.small_value.remote())
results += timeit("1:1 actor calls sync", actor_sync)
a = Actor.remote()
def actor_async():
ray.get([a.small_value.remote() for _ in range(1000)])
results += timeit("1:1 actor calls async", actor_async, 1000)
a = Actor.options(max_concurrency=16).remote()
def actor_concurrent():
ray.get([a.small_value.remote() for _ in range(1000)])
results += timeit("1:1 actor calls concurrent", actor_concurrent, 1000)
n = 5000
n_cpu = multiprocessing.cpu_count() // 2
actors = [Actor._remote() for _ in range(n_cpu)]
client = Client.remote(actors)
def actor_async_direct():
ray.get(client.small_value_batch.remote(n))
results += timeit("1:n actor calls async", actor_async_direct, n * len(actors))
n_cpu = multiprocessing.cpu_count() // 2
a = [Actor.remote() for _ in range(n_cpu)]
@ray.remote
def work(actors):
ray.get([actors[i % n_cpu].small_value.remote() for i in range(n)])
def actor_multi2():
ray.get([work.remote(a) for _ in range(m)])
results += timeit("n:n actor calls async", actor_multi2, m * n)
n = 1000
actors = [Actor._remote() for _ in range(n_cpu)]
clients = [Client.remote(a) for a in actors]
def actor_multi2_direct_arg():
ray.get([c.small_value_batch_arg.remote(n) for c in clients])
results += timeit(
"n:n actor calls with arg async", actor_multi2_direct_arg, n * len(clients)
)
a = AsyncActor.remote()
def actor_sync():
ray.get(a.small_value.remote())
results += timeit("1:1 async-actor calls sync", actor_sync)
a = AsyncActor.remote()
def async_actor():
ray.get([a.small_value.remote() for _ in range(1000)])
results += timeit("1:1 async-actor calls async", async_actor, 1000)
a = AsyncActor.remote()
def async_actor():
ray.get([a.small_value_with_arg.remote(i) for i in range(1000)])
results += timeit("1:1 async-actor calls with args async", async_actor, 1000)
n = 5000
n_cpu = multiprocessing.cpu_count() // 2
actors = [AsyncActor.remote() for _ in range(n_cpu)]
client = Client.remote(actors)
def async_actor_async():
ray.get(client.small_value_batch.remote(n))
results += timeit("1:n async-actor calls async", async_actor_async, n * len(actors))
n = 5000
m = 4
n_cpu = multiprocessing.cpu_count() // 2
a = [AsyncActor.remote() for _ in range(n_cpu)]
@ray.remote
def async_actor_work(actors):
ray.get([actors[i % n_cpu].small_value.remote() for i in range(n)])
def async_actor_multi():
ray.get([async_actor_work.remote(a) for _ in range(m)])
results += timeit("n:n async-actor calls async", async_actor_multi, m * n)
ray.shutdown()
############################
# End of channel perf tests.
############################
NUM_PGS = 100
NUM_BUNDLES = 1
ray.init(resources={"custom": 100})
def placement_group_create_removal(num_pgs):
pgs = [
ray.util.placement_group(
bundles=[{"custom": 0.001} for _ in range(NUM_BUNDLES)]
)
for _ in range(num_pgs)
]
[pg.wait(timeout_seconds=30) for pg in pgs]
# Include placement group removal here to clean up.
# If we don't clean up placement groups, the whole performance
# gets slower as it runs more.
# Since timeit function runs multiple times without
# the cleaning logic, we should have this method here.
for pg in pgs:
ray.util.remove_placement_group(pg)
results += timeit(
"placement group create/removal",
lambda: placement_group_create_removal(NUM_PGS),
NUM_PGS,
)
ray.shutdown()
client_microbenchmark_main(results)
return results
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
import atexit
import os
import signal
import sys
import time
"""
This is a lightweight "reaper" process used to ensure that ray processes are
cleaned up properly when the main ray process dies unexpectedly (e.g.,
segfaults or gets SIGKILLed). Note that processes may not be cleaned up
properly if this process is SIGTERMed or SIGKILLed.
It detects that its parent has died by reading from stdin, which must be
inherited from the parent process so that the OS will deliver an EOF if the
parent dies. When this happens, the reaper process kills the rest of its
process group (first attempting graceful shutdown with SIGTERM, then escalating
to SIGKILL).
"""
SIGTERM_GRACE_PERIOD_SECONDS = 1
def reap_process_group(*args):
def sigterm_handler(*args):
# Give a one-second grace period for other processes to clean up.
time.sleep(SIGTERM_GRACE_PERIOD_SECONDS)
# SIGKILL the pgroup (including ourselves) as a last-resort.
if sys.platform == "win32":
atexit.unregister(sigterm_handler)
os.kill(0, signal.CTRL_BREAK_EVENT)
else:
os.killpg(0, signal.SIGKILL)
# Set a SIGTERM handler to handle SIGTERMing ourselves with the group.
if sys.platform == "win32":
atexit.register(sigterm_handler)
else:
signal.signal(signal.SIGTERM, sigterm_handler)
# Our parent must have died, SIGTERM the group (including ourselves).
if sys.platform == "win32":
os.kill(0, signal.CTRL_C_EVENT)
else:
os.killpg(0, signal.SIGTERM)
def main():
# Read from stdin forever. Because stdin is a file descriptor
# inherited from our parent process, we will get an EOF if the parent
# dies, which is signaled by an empty return from read().
# We intentionally don't set any signal handlers here, so a SIGTERM from
# the parent can be used to kill this process gracefully without it killing
# the rest of the process group.
while len(sys.stdin.read()) != 0:
pass
reap_process_group()
if __name__ == "__main__":
main()
@@ -0,0 +1,473 @@
import json
import logging
import os
from typing import Dict, Optional, Tuple
import ray
import ray._private.ray_constants as ray_constants
from ray._common.constants import HEAD_NODE_RESOURCE_NAME, NODE_ID_PREFIX
from ray._common.utils import RESOURCE_CONSTRAINT_PREFIX
from ray._private import accelerators
from ray._private.accelerators import AcceleratorManager
from ray._private.resource_isolation_config import ResourceIsolationConfig
logger = logging.getLogger(__name__)
class ResourceAndLabelSpec:
"""Represents the resource and label configuration passed to a raylet.
All fields can be None. Before starting services, resolve() should be
called to return a ResourceAndLabelSpec with unknown values filled in with
merged values based on the local machine and user specifications.
"""
def __init__(
self,
num_cpus: Optional[int] = None,
num_gpus: Optional[int] = None,
memory: Optional[float] = None,
object_store_memory: Optional[float] = None,
resources: Optional[Dict[str, float]] = None,
labels: Optional[Dict[str, str]] = None,
):
"""
Initialize a ResourceAndLabelSpec
Args:
num_cpus: The CPUs allocated for this raylet.
num_gpus: The GPUs allocated for this raylet.
memory: The memory allocated for this raylet.
object_store_memory: The object store memory allocated for this raylet.
resources: The custom resources allocated for this raylet.
labels: The labels associated with this node. Labels can be used along
with resources for scheduling.
"""
self.num_cpus = num_cpus
self.num_gpus = num_gpus
self.memory = memory
self.object_store_memory = object_store_memory
self.resources = resources
self.labels = labels
self._is_resolved = False
def resolved(self) -> bool:
"""Returns if resolve() has been called for this ResourceAndLabelSpec
and default values are filled out."""
return self._is_resolved
def _all_fields_set(self) -> bool:
"""Returns whether all fields in this ResourceAndLabelSpec are not None."""
return all(
v is not None
for v in (
self.num_cpus,
self.num_gpus,
self.memory,
self.object_store_memory,
self.resources,
self.labels,
)
)
def to_resource_dict(self):
"""Returns a dict suitable to pass to raylet initialization.
This renames num_cpus / num_gpus to "CPU" / "GPU",
and check types and values.
"""
assert self.resolved()
resources = dict(
self.resources,
CPU=self.num_cpus,
GPU=self.num_gpus,
memory=int(self.memory),
object_store_memory=int(self.object_store_memory),
)
resources = {
resource_label: resource_quantity
for resource_label, resource_quantity in resources.items()
if resource_quantity != 0
}
# Check types.
for resource_label, resource_quantity in resources.items():
assert isinstance(resource_quantity, int) or isinstance(
resource_quantity, float
), (
f"{resource_label} ({type(resource_quantity)}): " f"{resource_quantity}"
)
if (
isinstance(resource_quantity, float)
and not resource_quantity.is_integer()
):
raise ValueError(
"Resource quantities must all be whole numbers. "
"Violated by resource '{}' in {}.".format(resource_label, resources)
)
if resource_quantity < 0:
raise ValueError(
"Resource quantities must be nonnegative. "
"Violated by resource '{}' in {}.".format(resource_label, resources)
)
if resource_quantity > ray_constants.MAX_RESOURCE_QUANTITY:
raise ValueError(
"Resource quantities must be at most {}. "
"Violated by resource '{}' in {}.".format(
ray_constants.MAX_RESOURCE_QUANTITY, resource_label, resources
)
)
return resources
def resolve(
self,
is_head: bool,
node_ip_address: Optional[str] = None,
resource_isolation_config: Optional[ResourceIsolationConfig] = None,
) -> "ResourceAndLabelSpec":
"""Fills out this ResourceAndLabelSpec instance with merged values from system defaults and user specification.
Args:
is_head: Whether this is the head node.
node_ip_address: The IP address of the node that we are on.
This is used to automatically create a node id resource.
resource_isolation_config: Optional resource isolation config. When
enabled and memory is not explicitly set, the system reserved
memory for resource isolation is subtracted from available user memory.
Returns:
ResourceAndLabelSpec: This instance with all fields resolved.
"""
self._resolve_resources(is_head=is_head, node_ip_address=node_ip_address)
# Resolve accelerator-specific resources
(
accelerator_manager,
num_accelerators,
) = ResourceAndLabelSpec._get_current_node_accelerator(
self.num_gpus, self.resources
)
self._resolve_accelerator_resources(accelerator_manager, num_accelerators)
# Default num_gpus value if unset by user and unable to auto-detect.
if self.num_gpus is None:
self.num_gpus = 0
# Resolve and merge node labels from all sources (params, env, and default).
self._resolve_labels(accelerator_manager)
# Resolve memory resources
self._resolve_memory_resources(resource_isolation_config)
self._is_resolved = True
assert self._all_fields_set()
return self
@staticmethod
def _load_env_resources() -> Dict[str, float]:
"""Load resource overrides from the environment, if present."""
env_resources = {}
env_string = os.getenv(ray_constants.RESOURCES_ENVIRONMENT_VARIABLE)
if env_string:
try:
env_resources = json.loads(env_string)
except Exception:
logger.exception(f"Failed to load {env_string}")
raise
logger.debug(f"Autoscaler overriding resources: {env_resources}.")
return env_resources
@staticmethod
def _merge_resources(env_dict: Dict[str, float], params_dict: Dict[str, float]):
"""Merge environment and Ray param-provided resources, with env values taking precedence.
Returns separated special case params (CPU/GPU/memory) and the merged resource dict.
"""
num_cpus = env_dict.pop("CPU", None)
num_gpus = env_dict.pop("GPU", None)
memory = env_dict.pop("memory", None)
object_store_memory = env_dict.pop("object_store_memory", None)
result = params_dict.copy()
result.update(env_dict)
for key in set(env_dict.keys()).intersection(params_dict or {}):
if params_dict[key] != env_dict[key]:
logger.warning(
f"Autoscaler is overriding your resource: {key}: "
f"{params_dict[key]} with {env_dict[key]}."
)
return num_cpus, num_gpus, memory, object_store_memory, result
def _resolve_resources(
self, is_head: bool, node_ip_address: Optional[str] = None
) -> None:
"""Resolve CPU, GPU, and custom resources. Merges resources from environment,
Ray params, and defaults in that order of precedence."""
# Load environment override resources and merge with resources passed
# in from Ray Params. Separates special case params if found in env.
env_resources = ResourceAndLabelSpec._load_env_resources()
(
num_cpus,
num_gpus,
memory,
object_store_memory,
merged_resources,
) = ResourceAndLabelSpec._merge_resources(env_resources, self.resources or {})
self.num_cpus = self.num_cpus if num_cpus is None else num_cpus
self.num_gpus = self.num_gpus if num_gpus is None else num_gpus
self.memory = self.memory if memory is None else memory
self.object_store_memory = (
self.object_store_memory
if object_store_memory is None
else object_store_memory
)
self.resources = merged_resources
if node_ip_address is None:
node_ip_address = ray.util.get_node_ip_address()
# Automatically create a node id resource on each node. This is
# queryable with ray._private.state.node_ids() and
# ray._private.state.current_node_id().
self.resources[NODE_ID_PREFIX + node_ip_address] = 1.0
# Automatically create a head node resource.
if HEAD_NODE_RESOURCE_NAME in self.resources:
raise ValueError(
f"{HEAD_NODE_RESOURCE_NAME}"
" is a reserved resource name, use another name instead."
)
if is_head:
self.resources[HEAD_NODE_RESOURCE_NAME] = 1.0
# Auto-detect CPU count if not explicitly set
if self.num_cpus is None:
self.num_cpus = ray._private.utils.get_num_cpus()
@staticmethod
def _load_env_labels() -> Dict[str, str]:
env_override_labels = {}
env_override_labels_string = os.getenv(
ray_constants.LABELS_ENVIRONMENT_VARIABLE
)
if env_override_labels_string:
try:
env_override_labels = json.loads(env_override_labels_string)
except Exception:
logger.exception(f"Failed to load {env_override_labels_string}")
raise
logger.info(f"Autoscaler overriding labels: {env_override_labels}.")
return env_override_labels
@staticmethod
def _get_default_labels(
accelerator_manager: Optional[AcceleratorManager],
) -> Dict[str, str]:
default_labels = {}
# Get environment variables populated from K8s Pod Spec
node_group = os.environ.get(ray._raylet.NODE_TYPE_NAME_ENV, "")
market_type = os.environ.get(ray._raylet.NODE_MARKET_TYPE_ENV, "")
availability_region = os.environ.get(ray._raylet.NODE_REGION_ENV, "")
availability_zone = os.environ.get(ray._raylet.NODE_ZONE_ENV, "")
# Map environment variables to default ray node labels
if market_type:
default_labels[ray._raylet.RAY_NODE_MARKET_TYPE_KEY] = market_type
if node_group:
default_labels[ray._raylet.RAY_NODE_GROUP_KEY] = node_group
if availability_zone:
default_labels[ray._raylet.RAY_NODE_ZONE_KEY] = availability_zone
if availability_region:
default_labels[ray._raylet.RAY_NODE_REGION_KEY] = availability_region
# Get accelerator type from AcceleratorManager
if accelerator_manager:
accelerator_type = accelerator_manager.get_current_node_accelerator_type()
if accelerator_type:
default_labels[
ray._raylet.RAY_NODE_ACCELERATOR_TYPE_KEY
] = accelerator_type
# Set TPU specific default labels to enable multi-host scheduling.
if accelerator_manager.get_resource_name() == "TPU":
tpu_labels = accelerator_manager.get_current_node_accelerator_labels()
if tpu_labels:
default_labels.update(tpu_labels)
return default_labels
def _resolve_labels(
self, accelerator_manager: Optional[AcceleratorManager]
) -> None:
"""Resolve and merge environment override, user-input from params, and Ray default
labels in that order of precedence."""
# Start with a dictionary filled out with Ray default labels
merged = ResourceAndLabelSpec._get_default_labels(accelerator_manager)
# Merge user-specified labels from Ray params
for key, val in (self.labels or {}).items():
if key in merged and merged[key] != val:
logger.warning(
f"User label is overriding Ray default label: {key}: "
f"{key}: {merged[key]} to "
f"{key}: {self.labels[key]}."
)
merged[key] = val
# Merge autoscaler override labels from environment
env_labels = ResourceAndLabelSpec._load_env_labels()
for key, val in (env_labels or {}).items():
if key in merged and merged[key] != val:
logger.warning(
"Autoscaler is overriding your label:"
f"{key}: {merged[key]} to "
f"{key}: {env_labels[key]}."
)
merged[key] = val
self.labels = merged
def _resolve_accelerator_resources(self, accelerator_manager, num_accelerators):
"""Detect and update accelerator resources on a node."""
if not accelerator_manager:
return
accelerator_resource_name = accelerator_manager.get_resource_name()
visible_accelerator_ids = (
accelerator_manager.get_current_process_visible_accelerator_ids()
)
# Check that the number of accelerators that the raylet wants doesn't
# exceed the amount allowed by visible accelerator ids.
if (
num_accelerators is not None
and visible_accelerator_ids is not None
and num_accelerators > len(visible_accelerator_ids)
):
raise ValueError(
f"Attempting to start raylet with {num_accelerators} "
f"{accelerator_resource_name}, "
f"but {accelerator_manager.get_visible_accelerator_ids_env_var()} "
f"contains {visible_accelerator_ids}."
)
if accelerator_resource_name == "GPU":
self.num_gpus = num_accelerators
else:
self.resources[accelerator_resource_name] = num_accelerators
accelerator_type = accelerator_manager.get_current_node_accelerator_type()
if accelerator_type:
self.resources[f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"] = 1
additional_resources = (
accelerator_manager.get_current_node_additional_resources()
)
if additional_resources:
self.resources.update(additional_resources)
def _resolve_memory_resources(
self,
resource_isolation_config: Optional[ResourceIsolationConfig] = None,
):
"""
Resolves logical and object store memory resources if not
explicitly set.
Args:
resource_isolation_config: Optional resource isolation config. When
enabled and memory is not explicitly set, the system reserved
memory for resource isolation is subtracted from available user memory.
"""
# Choose a default object store size.
system_memory = ray._common.utils.get_system_memory()
if (
resource_isolation_config is not None
and resource_isolation_config.is_enabled()
):
available_memory_bytes = (
system_memory
- resource_isolation_config.system_reserved_memory
- ray_constants.DEFAULT_USER_PHYSICAL_LOGICAL_MEMORY_LIMIT_BUFFER_BYTES
)
else:
available_memory_bytes = ray._private.utils.estimate_available_memory()
if self.object_store_memory is None:
self.object_store_memory = ray._private.utils.resolve_object_store_memory(
available_memory_bytes
)
memory = self.memory
if memory is None:
memory = available_memory_bytes - self.object_store_memory
if memory < 100e6 and memory < 0.05 * system_memory:
raise ValueError(
"After taking into account object store and redis memory "
"usage, the amount of memory on this node available for "
"tasks and actors ({} GB) is less than {}% of total. "
"You can adjust these settings with "
"ray.init(memory=<bytes>, "
"object_store_memory=<bytes>).".format(
round(memory / 1e9, 2), int(100 * (memory / system_memory))
)
)
self.memory = memory
@staticmethod
def _get_current_node_accelerator(
num_gpus: Optional[int], resources: Dict[str, float]
) -> Tuple[AcceleratorManager, int]:
"""
Returns the AcceleratorManager and accelerator count for the accelerator
associated with this node. This assumes each node has at most one accelerator type.
If no accelerators are present, returns None.
The resolved accelerator count uses num_gpus (for GPUs) or resources if set, and
otherwise falls back to the count auto-detected by the AcceleratorManager. The
resolved accelerator count is capped by the number of visible accelerators.
Args:
num_gpus: GPU count (if provided by user).
resources: Resource dictionary containing custom resource keys.
Returns:
Tuple[Optional[AcceleratorManager], int]: A tuple containing the accelerator
manager (or None) the final resolved accelerator count.
"""
for resource_name in accelerators.get_all_accelerator_resource_names():
accelerator_manager = accelerators.get_accelerator_manager_for_resource(
resource_name
)
if accelerator_manager is None:
continue
# Respect configured value for GPUs if set
if resource_name == "GPU":
num_accelerators = num_gpus
else:
num_accelerators = resources.get(resource_name)
if num_accelerators is None:
num_accelerators = (
accelerator_manager.get_current_node_num_accelerators()
)
visible_accelerator_ids = (
accelerator_manager.get_current_process_visible_accelerator_ids()
)
if visible_accelerator_ids is not None:
num_accelerators = min(
num_accelerators, len(visible_accelerator_ids)
)
if num_accelerators > 0:
return accelerator_manager, num_accelerators
return None, 0
@@ -0,0 +1,282 @@
import logging
from typing import Optional
import ray._common.utils
import ray._private.ray_constants as ray_constants
import ray._private.utils as utils
logger = logging.getLogger(__name__)
# See https://docs.kernel.org/admin-guide/cgroup-v2.html#weights
# for information about cpu weights
_CGROUP_CPU_MAX_WEIGHT: int = 10000
class ResourceIsolationConfig:
"""Configuration for enabling resource isolation by reserving memory and cpu for ray system processes through cgroupv2.
Validates configuration for resource isolation by enforcing types, correct combinations of values, applying default values,
and sanity checking cpu and memory reservations. Also, converts system_reserved_cpu into cpu.weights for cgroupv2.
Attributes:
enable_resource_isolation: True if cgroupv2 based isolation of ray
system processes is enabled.
cgroup_path: The path for the cgroup the raylet should use to enforce
resource isolation.
system_reserved_cpu: The amount of cores reserved for ray system
processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_CPU_CORES
and < the total number of cores available.
system_reserved_memory: The amount of memory in bytes reserved
for ray system processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_MEMORY_BYTES
and < the total memory available.
TODO(54703): Link documentation when it's available.
"""
def __init__(
self,
enable_resource_isolation: bool = False,
cgroup_path: Optional[str] = None,
system_reserved_cpu: Optional[float] = None,
system_reserved_memory: Optional[int] = None,
):
"""
Raises:
ValueError: On invalid inputs.
Args:
enable_resource_isolation: True if cgroupv2 based isolation of ray
system processes is enabled.
cgroup_path: The path for the cgroup the raylet should use to enforce
resource isolation.
system_reserved_cpu: The amount of cores reserved for ray system
processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_CPU_CORES
and < the total number of cores available.
system_reserved_memory: The amount of memory in bytes reserved
for ray system processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_MEMORY_BYTES
and < the total memory available.
"""
self._resource_isolation_enabled = enable_resource_isolation
self.cgroup_path = cgroup_path
self.system_reserved_memory = system_reserved_memory
self.system_pids = ""
# cgroupv2 cpu.weight calculated from system_reserved_cpu assumes ray uses all available cores.
self.system_reserved_cpu_weight: int = None
if not enable_resource_isolation:
if self.cgroup_path:
raise ValueError(
"cgroup_path cannot be set when resource isolation is not enabled. "
"Set enable_resource_isolation to True if you're using ray.init or use the "
"--enable-resource-isolation flag if you're using the ray cli."
)
if system_reserved_cpu is not None:
raise ValueError(
"system_reserved_cpu cannot be set when resource isolation is not enabled. "
"Set enable_resource_isolation to True if you're using ray.init or use the "
"--enable-resource-isolation flag if you're using the ray cli."
)
if system_reserved_memory is not None:
raise ValueError(
"system_reserved_memory cannot be set when resource isolation is not enabled. "
"Set enable_resource_isolation to True if you're using ray.init or use the "
"--enable-resource-isolation flag if you're using the ray cli."
)
return
self.system_reserved_cpu_weight = self._validate_and_get_system_reserved_cpu(
system_reserved_cpu
)
self.system_reserved_memory = self._validate_and_get_system_reserved_memory(
system_reserved_memory
)
self.cgroup_path = self._validate_and_get_cgroup_path(cgroup_path)
def is_enabled(self) -> bool:
return self._resource_isolation_enabled
def add_system_pids(self, system_pids: str):
"""A comma-separated list of pids to move into the system cgroup."""
self.system_pids = system_pids
@staticmethod
def _validate_and_get_cgroup_path(cgroup_path: Optional[str]) -> str:
"""Returns the ray_constants.DEFAULT_CGROUP_PATH if cgroup_path is not specified.
Args:
cgroup_path: The path for the cgroup the raylet should use to enforce
resource isolation.
Returns:
str: The validated cgroup path.
Raises:
ValueError: If cgroup_path is not a string.
"""
if not cgroup_path:
cgroup_path = ray_constants.DEFAULT_CGROUP_PATH
if not isinstance(cgroup_path, str):
raise ValueError(
f"Invalid value={cgroup_path} for cgroup_path. "
"Use a string to represent the path for the cgroup that the raylet should use "
"to enable resource isolation."
)
return cgroup_path
@staticmethod
def _validate_and_get_system_reserved_cpu(
system_reserved_cpu: Optional[float],
) -> int:
"""If system_reserved_cpu is specified, validates it, otherwise returns the default value.
Validation entails checking the type, ensuring that the value is in range, and converts it
into cpu.weights for cgroupv2. See https://docs.kernel.org/admin-guide/cgroup-v2.html#weights
for more information.
If system_reserved_cpu is not specified, returns a default value between
[DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES, DEFAULT_MAX_SYSTEM_RESERVED_CPU_CORES].
# TODO(54703): The errors from this method are user-facing and thus need
to be linked the user-facing documentation once it's available.
Args:
system_reserved_cpu: The amount of cores reserved for ray system
processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_CPU_CORES
and < the total number of cores available.
Raises:
ValueError: If system_reserved_cpu is specified, but invalid or if the system
does not have enough available cpus.
Returns:
The cgroup v2 cpu.weight value derived from the reserved cpu cores.
"""
available_system_cpus = utils.get_num_cpus(truncate=False)
if available_system_cpus < ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES:
raise ValueError(
f"The available number of cpu cores on this system {available_system_cpus} is less than "
f"the minimum amount that is required for ray's system processes. "
f"Pick a number of cpu cores greater than or equal to {ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES}"
)
if system_reserved_cpu is None:
system_reserved_cpu = float(
min(
max(
ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES,
ray_constants.DEFAULT_SYSTEM_RESERVED_CPU_PROPORTION
* available_system_cpus,
),
ray_constants.DEFAULT_MAX_SYSTEM_RESERVED_CPU_CORES,
)
)
if not (
isinstance(system_reserved_cpu, float)
or isinstance(system_reserved_cpu, int)
):
raise ValueError(
f"Invalid value={system_reserved_cpu} for system_reserved_cpu. "
"Use a float to represent the number of cores that need to be reserved for "
"ray system processes to enable resource isolation."
)
system_reserved_cpu = float(system_reserved_cpu)
if system_reserved_cpu < ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES:
raise ValueError(
f"The requested system_reserved_cpu={system_reserved_cpu} is less than "
f"the minimum number of cpus that can be used for resource isolation. "
"Pick a number of cpu cores to reserve for ray system processes "
f"greater than or equal to {ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_CPU_CORES}"
)
if system_reserved_cpu >= available_system_cpus:
raise ValueError(
f"The requested system_reserved_cpu={system_reserved_cpu} is greater than or equal to "
f"the number of cpus available={available_system_cpus}. "
"Pick a smaller number of cpu cores to reserve for ray system processes."
)
# Converting the number of cores the user defined into cpu.weights
# This assumes that ray is allowed to use all available CPU
# cores and distribute them between system, worker and
# user processes
return int(
(system_reserved_cpu / float(available_system_cpus))
* _CGROUP_CPU_MAX_WEIGHT
)
@staticmethod
def _validate_and_get_system_reserved_memory(
system_reserved_memory: Optional[int],
) -> int:
"""If system_reserved_memory is not specified, returns the default value. Otherwise,
checks the type, makes sure that the value is in range.
Args:
system_reserved_memory: The amount of memory in bytes reserved
for ray system processes. Must be >= ray_constants.MINIMUM_SYSTEM_RESERVED_MEMORY_BYTES
and < the total memory available.
Returns:
int: The validated system reserved memory in bytes.
Raises:
ValueError: If system_reserved_memory is specified, but invalid.
"""
available_system_memory = ray._common.utils.get_system_memory()
if (
available_system_memory
< ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES
):
raise ValueError(
f"The available memory on this system {available_system_memory} is less than "
f"the minimum amount that is required for ray's system processes. "
f"Pick a number of bytes greater than or equal to {ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES}"
)
if system_reserved_memory is None:
system_reserved_memory = int(
min(
max(
ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES,
ray_constants.DEFAULT_SYSTEM_RESERVED_MEMORY_PROPORTION
* available_system_memory,
),
ray_constants.DEFAULT_MAX_SYSTEM_RESERVED_MEMORY_BYTES,
)
)
if not isinstance(system_reserved_memory, int):
raise ValueError(
f"Invalid value {system_reserved_memory} for system_reserved_memory. "
"Use an integer to represent the number bytes that need to be reserved for "
"ray system processes to enable resource isolation."
)
if (
system_reserved_memory
< ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES
):
raise ValueError(
f"The requested system_reserved_memory {system_reserved_memory} is less than "
f"the minimum number of bytes that can be used for resource isolation. "
"Pick a number of bytes to reserve for ray system processes "
f"greater than or equal to {ray_constants.DEFAULT_MIN_SYSTEM_RESERVED_MEMORY_BYTES}"
)
if system_reserved_memory > available_system_memory:
raise ValueError(
f"The total requested system_reserved_memory={system_reserved_memory} "
f"is greater than the amount of memory available={available_system_memory}."
)
return system_reserved_memory
@@ -0,0 +1,42 @@
# TODO(hjiang): All existing pythons are not using bazel as build system, which leads to missing BUILD file and targets.
# Revisit if we decide to support bazel build in the future.
load("@rules_python//python:defs.bzl", "py_library")
package(default_visibility = ["//visibility:public"])
py_library(
name = "validation",
srcs = ["validation.py"],
)
py_library(
name = "utils",
srcs = ["utils.py"],
)
py_library(
name = "virtualenv_utils",
srcs = ["virtualenv_utils.py"],
deps = [
":utils",
],
)
py_library(
name = "dependency_utils",
srcs = ["dependency_utils.py"],
deps = [
":utils",
],
)
py_library(
name = "uv",
srcs = ["uv.py"],
deps = [
":dependency_utils",
":utils",
":virtualenv_utils",
],
)
@@ -0,0 +1,3 @@
# List of files to exclude from the Ray directory when using runtime_env for
# Ray development. These are not necessary in the Ray workers.
RAY_WORKER_DEV_EXCLUDES = ["raylet", "gcs_server", "cpp/", "tests/", "core/src"]
@@ -0,0 +1,334 @@
#!/usr/bin/env python
from __future__ import with_statement
import logging
import optparse
import os
import os.path
import re
import shutil
import subprocess
import sys
import itertools
__version__ = "0.5.7"
logger = logging.getLogger()
env_bin_dir = "bin"
if sys.platform == "win32":
env_bin_dir = "Scripts"
_WIN32 = True
else:
_WIN32 = False
class UserError(Exception):
pass
def _dirmatch(path, matchwith):
"""Check if path is within matchwith's tree.
>>> _dirmatch('/home/foo/bar', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar/', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar/etc', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar2', '/home/foo/bar')
False
>>> _dirmatch('/home/foo/bar2/etc', '/home/foo/bar')
False
"""
matchlen = len(matchwith)
if path.startswith(matchwith) and path[matchlen : matchlen + 1] in [os.sep, ""]:
return True
return False
def _virtualenv_sys(venv_path):
"""obtain version and path info from a virtualenv."""
executable = os.path.join(venv_path, env_bin_dir, "python")
if _WIN32:
env = os.environ.copy()
else:
env = {}
# Must use "executable" as the first argument rather than as the
# keyword argument "executable" to get correct value from sys.path
p = subprocess.Popen(
[
executable,
"-c",
"import sys;"
'print ("%d.%d" % (sys.version_info.major, sys.version_info.minor));'
'print ("\\n".join(sys.path));',
],
env=env,
stdout=subprocess.PIPE,
)
stdout, err = p.communicate()
assert not p.returncode and stdout
lines = stdout.decode("utf-8").splitlines()
return lines[0], list(filter(bool, lines[1:]))
def clone_virtualenv(src_dir, dst_dir):
if not os.path.exists(src_dir):
raise UserError("src dir %r does not exist" % src_dir)
if os.path.exists(dst_dir):
raise UserError("dest dir %r exists" % dst_dir)
# sys_path = _virtualenv_syspath(src_dir)
logger.info("cloning virtualenv '%s' => '%s'..." % (src_dir, dst_dir))
shutil.copytree(
src_dir, dst_dir, symlinks=True, ignore=shutil.ignore_patterns("*.pyc")
)
version, sys_path = _virtualenv_sys(dst_dir)
logger.info("fixing scripts in bin...")
fixup_scripts(src_dir, dst_dir, version)
has_old = lambda s: any(i for i in s if _dirmatch(i, src_dir)) # noqa: E731
if has_old(sys_path):
# only need to fix stuff in sys.path if we have old
# paths in the sys.path of new python env. right?
logger.info("fixing paths in sys.path...")
fixup_syspath_items(sys_path, src_dir, dst_dir)
v_sys = _virtualenv_sys(dst_dir)
remaining = has_old(v_sys[1])
assert not remaining, v_sys
fix_symlink_if_necessary(src_dir, dst_dir)
def fix_symlink_if_necessary(src_dir, dst_dir):
# sometimes the source virtual environment has symlinks that point to itself
# one example is $OLD_VIRTUAL_ENV/local/lib points to $OLD_VIRTUAL_ENV/lib
# this function makes sure
# $NEW_VIRTUAL_ENV/local/lib will point to $NEW_VIRTUAL_ENV/lib
# usually this goes unnoticed unless one tries to upgrade a package though pip,
# so this bug is hard to find.
logger.info("scanning for internal symlinks that point to the original virtual env")
for dirpath, dirnames, filenames in os.walk(dst_dir):
for a_file in itertools.chain(filenames, dirnames):
full_file_path = os.path.join(dirpath, a_file)
if os.path.islink(full_file_path):
target = os.path.realpath(full_file_path)
if target.startswith(src_dir):
new_target = target.replace(src_dir, dst_dir)
logger.debug("fixing symlink in %s" % (full_file_path,))
os.remove(full_file_path)
os.symlink(new_target, full_file_path)
def fixup_scripts(old_dir, new_dir, version, rewrite_env_python=False):
bin_dir = os.path.join(new_dir, env_bin_dir)
root, dirs, files = next(os.walk(bin_dir))
pybinre = re.compile(r"pythonw?([0-9]+(\.[0-9]+(\.[0-9]+)?)?)?$")
for file_ in files:
filename = os.path.join(root, file_)
if file_ in ["python", "python%s" % version, "activate_this.py"]:
continue
elif file_.startswith("python") and pybinre.match(file_):
# ignore other possible python binaries
continue
elif file_.endswith(".pyc"):
# ignore compiled files
continue
elif file_ == "activate" or file_.startswith("activate."):
fixup_activate(os.path.join(root, file_), old_dir, new_dir)
elif os.path.islink(filename):
fixup_link(filename, old_dir, new_dir)
elif os.path.isfile(filename):
fixup_script_(
root,
file_,
old_dir,
new_dir,
version,
rewrite_env_python=rewrite_env_python,
)
def fixup_script_(root, file_, old_dir, new_dir, version, rewrite_env_python=False):
old_shebang = "#!%s/bin/python" % os.path.normcase(os.path.abspath(old_dir))
new_shebang = "#!%s/bin/python" % os.path.normcase(os.path.abspath(new_dir))
env_shebang = "#!/usr/bin/env python"
filename = os.path.join(root, file_)
with open(filename, "rb") as f:
if f.read(2) != b"#!":
# no shebang
return
f.seek(0)
lines = f.readlines()
if not lines:
# warn: empty script
return
def rewrite_shebang(version=None):
logger.debug("fixing %s" % filename)
shebang = new_shebang
if version:
shebang = shebang + version
shebang = (shebang + "\n").encode("utf-8")
with open(filename, "wb") as f:
f.write(shebang)
f.writelines(lines[1:])
try:
bang = lines[0].decode("utf-8").strip()
except UnicodeDecodeError:
# binary file
return
# This takes care of the scheme in which shebang is of type
# '#!/venv/bin/python3' while the version of system python
# is of type 3.x e.g. 3.5.
short_version = bang[len(old_shebang) :]
if not bang.startswith("#!"):
return
elif bang == old_shebang:
rewrite_shebang()
elif bang.startswith(old_shebang) and bang[len(old_shebang) :] == version:
rewrite_shebang(version)
elif (
bang.startswith(old_shebang)
and short_version
and bang[len(old_shebang) :] == short_version
):
rewrite_shebang(short_version)
elif rewrite_env_python and bang.startswith(env_shebang):
if bang == env_shebang:
rewrite_shebang()
elif bang[len(env_shebang) :] == version:
rewrite_shebang(version)
else:
# can't do anything
return
def fixup_activate(filename, old_dir, new_dir):
logger.debug("fixing %s" % filename)
with open(filename, "rb") as f:
data = f.read().decode("utf-8")
data = data.replace(old_dir, new_dir)
with open(filename, "wb") as f:
f.write(data.encode("utf-8"))
def fixup_link(filename, old_dir, new_dir, target=None):
logger.debug("fixing %s" % filename)
if target is None:
target = os.readlink(filename)
origdir = os.path.dirname(os.path.abspath(filename)).replace(new_dir, old_dir)
if not os.path.isabs(target):
target = os.path.abspath(os.path.join(origdir, target))
rellink = True
else:
rellink = False
if _dirmatch(target, old_dir):
if rellink:
# keep relative links, but don't keep original in case it
# traversed up out of, then back into the venv.
# so, recreate a relative link from absolute.
target = target[len(origdir) :].lstrip(os.sep)
else:
target = target.replace(old_dir, new_dir, 1)
# else: links outside the venv, replaced with absolute path to target.
_replace_symlink(filename, target)
def _replace_symlink(filename, newtarget):
tmpfn = "%s.new" % filename
os.symlink(newtarget, tmpfn)
os.rename(tmpfn, filename)
def fixup_syspath_items(syspath, old_dir, new_dir):
for path in syspath:
if not os.path.isdir(path):
continue
path = os.path.normcase(os.path.abspath(path))
if _dirmatch(path, old_dir):
path = path.replace(old_dir, new_dir, 1)
if not os.path.exists(path):
continue
elif not _dirmatch(path, new_dir):
continue
root, dirs, files = next(os.walk(path))
for file_ in files:
filename = os.path.join(root, file_)
if filename.endswith(".pth"):
fixup_pth_file(filename, old_dir, new_dir)
elif filename.endswith(".egg-link"):
fixup_egglink_file(filename, old_dir, new_dir)
def fixup_pth_file(filename, old_dir, new_dir):
logger.debug("fixup_pth_file %s" % filename)
with open(filename, "r") as f:
lines = f.readlines()
has_change = False
for num, line in enumerate(lines):
line = (line.decode("utf-8") if hasattr(line, "decode") else line).strip()
if not line or line.startswith("#") or line.startswith("import "):
continue
elif _dirmatch(line, old_dir):
lines[num] = line.replace(old_dir, new_dir, 1)
has_change = True
if has_change:
with open(filename, "w") as f:
payload = os.linesep.join([line.strip() for line in lines]) + os.linesep
f.write(payload)
def fixup_egglink_file(filename, old_dir, new_dir):
logger.debug("fixing %s" % filename)
with open(filename, "rb") as f:
link = f.read().decode("utf-8").strip()
if _dirmatch(link, old_dir):
link = link.replace(old_dir, new_dir, 1)
with open(filename, "wb") as f:
link = (link + "\n").encode("utf-8")
f.write(link)
def main():
parser = optparse.OptionParser(
"usage: %prog [options] /path/to/existing/venv /path/to/cloned/venv"
)
parser.add_option(
"-v", action="count", dest="verbose", default=False, help="verbosity"
)
options, args = parser.parse_args()
try:
old_dir, new_dir = args
except ValueError:
print("virtualenv-clone %s" % (__version__,))
parser.error("not enough arguments given.")
old_dir = os.path.realpath(old_dir)
new_dir = os.path.realpath(new_dir)
loglevel = (logging.WARNING, logging.INFO, logging.DEBUG)[min(2, options.verbose)]
logging.basicConfig(level=loglevel, format="%(message)s")
try:
clone_virtualenv(old_dir, new_dir)
except UserError:
e = sys.exc_info()[1]
parser.error(str(e))
if __name__ == "__main__":
main()
@@ -0,0 +1,266 @@
import argparse
import logging
import os
import socket
import sys
import ray
import ray._private.ray_constants as ray_constants
from ray._common.utils import (
get_or_create_event_loop,
)
from ray._private import logging_utils
from ray._private.authentication.http_token_authentication import (
get_token_auth_middleware,
)
from ray._private.process_watcher import create_check_raylet_task
from ray._raylet import RUNTIME_ENV_AGENT_PORT_NAME, GcsClient, persist_port
from ray.core.generated import (
runtime_env_agent_pb2,
)
def import_libs():
my_dir = os.path.abspath(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(my_dir, "thirdparty_files")) # for aiohttp
sys.path.insert(0, my_dir) # for runtime_env_agent and runtime_env_consts
import_libs()
import aiohttp # noqa: E402
import runtime_env_consts # noqa: E402
from aiohttp import web # noqa: E402
from runtime_env_agent import RuntimeEnvAgent # noqa: E402
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Runtime env agent.")
parser.add_argument(
"--node-id",
required=True,
type=str,
help="the unique ID of this node.",
)
parser.add_argument(
"--node-ip-address",
required=True,
type=str,
help="the IP address of this node.",
)
parser.add_argument(
"--runtime-env-agent-port",
required=True,
type=int,
default=None,
help="The port on which the runtime env agent will receive HTTP requests.",
)
parser.add_argument(
"--session-dir",
required=True,
type=str,
default=None,
help="The path of this ray session directory.",
)
parser.add_argument(
"--gcs-address", required=True, type=str, help="The address (ip:port) of GCS."
)
parser.add_argument(
"--cluster-id-hex", required=True, type=str, help="The cluster id in hex."
)
parser.add_argument(
"--runtime-env-dir",
required=True,
type=str,
default=None,
help="Specify the path of the resource directory used by runtime_env.",
)
parser.add_argument(
"--logging-level",
required=False,
type=lambda s: logging.getLevelName(s.upper()),
default=ray_constants.LOGGER_LEVEL,
choices=ray_constants.LOGGER_LEVEL_CHOICES,
help=ray_constants.LOGGER_LEVEL_HELP,
)
parser.add_argument(
"--logging-format",
required=False,
type=str,
default=ray_constants.LOGGER_FORMAT,
help=ray_constants.LOGGER_FORMAT_HELP,
)
parser.add_argument(
"--logging-filename",
required=False,
type=str,
default=runtime_env_consts.RUNTIME_ENV_AGENT_LOG_FILENAME,
help="Specify the name of log file, "
'log to stdout if set empty, default is "{}".'.format(
runtime_env_consts.RUNTIME_ENV_AGENT_LOG_FILENAME
),
)
parser.add_argument(
"--logging-rotate-bytes",
required=True,
type=int,
help="Specify the max bytes for rotating log file",
)
parser.add_argument(
"--logging-rotate-backup-count",
required=True,
type=int,
help="Specify the backup count of rotated log file",
)
parser.add_argument(
"--log-dir",
required=True,
type=str,
default=None,
help="Specify the path of log directory.",
)
parser.add_argument(
"--temp-dir",
required=True,
type=str,
default=None,
help="Specify the path of the temporary directory use by Ray process.",
)
parser.add_argument(
"--stdout-filepath",
required=False,
type=str,
default="",
help="The filepath to dump runtime env agent stdout.",
)
parser.add_argument(
"--stderr-filepath",
required=False,
type=str,
default="",
help="The filepath to dump runtime env agent stderr.",
)
args = parser.parse_args()
# Disable log rotation for windows platform.
logging_rotation_bytes = args.logging_rotate_bytes if sys.platform != "win32" else 0
logging_rotation_backup_count = (
args.logging_rotate_backup_count if sys.platform != "win32" else 1
)
logging_params = dict(
logging_level=args.logging_level,
logging_format=args.logging_format,
log_dir=args.log_dir,
filename=args.logging_filename,
max_bytes=logging_rotation_bytes,
backup_count=logging_rotation_backup_count,
)
# Setup stdout/stderr redirect files if redirection enabled.
logging_utils.redirect_stdout_stderr_if_needed(
args.stdout_filepath,
args.stderr_filepath,
logging_rotation_bytes,
logging_rotation_backup_count,
)
gcs_client = GcsClient(address=args.gcs_address, cluster_id=args.cluster_id_hex)
agent = RuntimeEnvAgent(
runtime_env_dir=args.runtime_env_dir,
logging_params=logging_params,
gcs_client=gcs_client,
temp_dir=args.temp_dir,
address=args.node_ip_address,
runtime_env_agent_port=args.runtime_env_agent_port,
)
ray._raylet.setproctitle(ray_constants.AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT)
# POST /get_or_create_runtime_env
# body is serialzied protobuf GetOrCreateRuntimeEnvRequest
# reply is serialzied protobuf GetOrCreateRuntimeEnvReply
async def get_or_create_runtime_env(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.GetOrCreateRuntimeEnvRequest()
request.ParseFromString(data)
reply = await agent.GetOrCreateRuntimeEnv(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
# POST /delete_runtime_env_if_possible
# body is serialzied protobuf DeleteRuntimeEnvIfPossibleRequest
# reply is serialzied protobuf DeleteRuntimeEnvIfPossibleReply
async def delete_runtime_env_if_possible(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleRequest()
request.ParseFromString(data)
reply = await agent.DeleteRuntimeEnvIfPossible(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
# POST /get_runtime_envs_info
# body is serialzied protobuf GetRuntimeEnvsInfoRequest
# reply is serialzied protobuf GetRuntimeEnvsInfoReply
async def get_runtime_envs_info(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.GetRuntimeEnvsInfoRequest()
request.ParseFromString(data)
reply = await agent.GetRuntimeEnvsInfo(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
app = web.Application(middlewares=[get_token_auth_middleware(aiohttp)])
app.router.add_post("/get_or_create_runtime_env", get_or_create_runtime_env)
app.router.add_post(
"/delete_runtime_env_if_possible", delete_runtime_env_if_possible
)
app.router.add_post("/get_runtime_envs_info", get_runtime_envs_info)
loop = get_or_create_event_loop()
check_raylet_task = None
if sys.platform not in ["win32", "cygwin"]:
def parent_dead_callback(msg):
agent._logger.info(
"Raylet is dead! Exiting Runtime Env Agent. "
f"addr: {args.node_ip_address}, "
f"port: {args.runtime_env_agent_port}\n"
f"{msg}"
)
# No need to await this task.
check_raylet_task = create_check_raylet_task(
args.log_dir, gcs_client, parent_dead_callback, loop
)
port = args.runtime_env_agent_port or 0
infos = socket.getaddrinfo(args.node_ip_address, port, type=socket.SOCK_STREAM)
family, socktype, proto, _, sockaddr = infos[0]
sock = socket.socket(family, socktype, proto)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(sockaddr)
bound_port = sock.getsockname()[1]
persist_port(
args.session_dir,
args.node_id,
RUNTIME_ENV_AGENT_PORT_NAME,
bound_port,
)
try:
web.run_app(app, sock=sock, loop=loop)
except SystemExit as e:
agent._logger.info(f"SystemExit! {e}")
# We have to poke the task exception, or there's an error message
# "task exception was never retrieved".
if check_raylet_task is not None:
check_raylet_task.exception()
sys.exit(e.code)
@@ -0,0 +1,620 @@
import asyncio
import logging
import os
import time
import traceback
from collections import defaultdict
from dataclasses import dataclass
from typing import Callable, Dict, List, Set, Tuple
import ray
import ray._private.runtime_env.agent.runtime_env_consts as runtime_env_consts
from ray._common.utils import get_or_create_event_loop
from ray._private.ray_constants import (
DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS,
)
from ray._private.ray_logging import setup_component_logger
from ray._private.runtime_env.conda import CondaPlugin
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.default_impl import get_image_uri_plugin_cls
from ray._private.runtime_env.image_uri import ContainerPlugin
from ray._private.runtime_env.java_jars import JavaJarsPlugin
from ray._private.runtime_env.nsight import NsightPlugin
from ray._private.runtime_env.pip import PipPlugin
from ray._private.runtime_env.plugin import (
RuntimeEnvPlugin,
RuntimeEnvPluginManager,
create_for_plugin_if_needed,
)
from ray._private.runtime_env.py_executable import PyExecutablePlugin
from ray._private.runtime_env.py_modules import PyModulesPlugin
from ray._private.runtime_env.rocprof_sys import RocProfSysPlugin
from ray._private.runtime_env.uv import UvPlugin
from ray._private.runtime_env.working_dir import WorkingDirPlugin
from ray._raylet import GcsClient
from ray.core.generated import runtime_env_agent_pb2
from ray.core.generated.runtime_env_common_pb2 import (
RuntimeEnvState as ProtoRuntimeEnvState,
)
from ray.runtime_env import RuntimeEnv, RuntimeEnvConfig
default_logger = logging.getLogger(__name__)
# TODO(edoakes): this is used for unit tests. We should replace it with a
# better pluggability mechanism once available.
SLEEP_FOR_TESTING_S = os.environ.get("RAY_RUNTIME_ENV_SLEEP_FOR_TESTING_S")
@dataclass
class CreatedEnvResult:
# Whether or not the env was installed correctly.
success: bool
# If success is True, will be a serialized RuntimeEnvContext
# If success is False, will be an error message.
result: str
# The time to create a runtime env in ms.
creation_time_ms: int
# e.g., "working_dir"
UriType = str
class ReferenceTable:
"""
The URI reference table which is used for GC.
When the reference count is decreased to zero,
the URI should be removed from this table and
added to cache if needed.
"""
def __init__(
self,
uris_parser: Callable[[RuntimeEnv], Tuple[str, UriType]],
unused_uris_callback: Callable[[List[Tuple[str, UriType]]], None],
unused_runtime_env_callback: Callable[[str], None],
):
# Runtime Environment reference table. The key is serialized runtime env and
# the value is reference count.
self._runtime_env_reference: Dict[str, int] = defaultdict(int)
# URI reference table. The key is URI parsed from runtime env and the value
# is reference count.
self._uri_reference: Dict[str, int] = defaultdict(int)
self._uris_parser = uris_parser
self._unused_uris_callback = unused_uris_callback
self._unused_runtime_env_callback = unused_runtime_env_callback
# send the `DeleteRuntimeEnvIfPossible` RPC when the client exits. The URI won't
# be leaked now because the reference count will be reset to zero when the job
# finished.
self._reference_exclude_sources: Set[str] = {
"client_server",
}
def _increase_reference_for_uris(self, uris):
default_logger.debug(f"Increase reference for uris {uris}.")
for uri, _ in uris:
self._uri_reference[uri] += 1
def _decrease_reference_for_uris(self, uris):
default_logger.debug(f"Decrease reference for uris {uris}.")
unused_uris = list()
for uri, uri_type in uris:
if self._uri_reference[uri] > 0:
self._uri_reference[uri] -= 1
if self._uri_reference[uri] == 0:
unused_uris.append((uri, uri_type))
del self._uri_reference[uri]
else:
default_logger.warning(f"URI {uri} does not exist.")
if unused_uris:
default_logger.info(f"Unused uris {unused_uris}.")
self._unused_uris_callback(unused_uris)
return unused_uris
def _increase_reference_for_runtime_env(self, serialized_env: str):
default_logger.debug(f"Increase reference for runtime env {serialized_env}.")
self._runtime_env_reference[serialized_env] += 1
def _decrease_reference_for_runtime_env(self, serialized_env: str):
"""Decrease reference count for the given [serialized_env]. Throw exception if we cannot decrement reference."""
default_logger.debug(f"Decrease reference for runtime env {serialized_env}.")
unused = False
if self._runtime_env_reference[serialized_env] > 0:
self._runtime_env_reference[serialized_env] -= 1
if self._runtime_env_reference[serialized_env] == 0:
unused = True
del self._runtime_env_reference[serialized_env]
else:
default_logger.warning(f"Runtime env {serialized_env} does not exist.")
raise ValueError(
f"{serialized_env} cannot decrement reference since the reference count is 0"
)
if unused:
default_logger.info(f"Unused runtime env {serialized_env}.")
self._unused_runtime_env_callback(serialized_env)
def increase_reference(
self, runtime_env: RuntimeEnv, serialized_env: str, source_process: str
) -> None:
if source_process in self._reference_exclude_sources:
return
self._increase_reference_for_runtime_env(serialized_env)
uris = self._uris_parser(runtime_env)
self._increase_reference_for_uris(uris)
def decrease_reference(
self, runtime_env: RuntimeEnv, serialized_env: str, source_process: str
) -> None:
"""Decrease reference count for runtime env and uri. Throw exception if decrement reference count fails."""
if source_process in self._reference_exclude_sources:
return
self._decrease_reference_for_runtime_env(serialized_env)
uris = self._uris_parser(runtime_env)
self._decrease_reference_for_uris(uris)
@property
def runtime_env_refs(self) -> Dict[str, int]:
"""Return the runtime_env -> ref count mapping.
Returns:
The mapping of serialized runtime env -> ref count.
"""
return self._runtime_env_reference
class RuntimeEnvAgent:
"""An RPC server to create and delete runtime envs.
Attributes:
dashboard_agent: The DashboardAgent object contains global config.
"""
def __init__(
self,
runtime_env_dir: str,
logging_params: dict,
gcs_client: GcsClient,
temp_dir: str,
address: str,
runtime_env_agent_port: int,
):
"""Initialize the runtime env agent.
Args:
runtime_env_dir: Directory used to store runtime env resources.
logging_params: Keyword arguments forwarded to
:func:`setup_component_logger` to configure the agent logger.
gcs_client: GCS client used to fetch package data.
temp_dir: Temporary directory used by plugins (e.g. container plugin).
address: IP address that the agent is listening on, used for logging.
runtime_env_agent_port: Port that the agent is listening on, used for
logging.
"""
super().__init__()
self._logger = default_logger
self._logging_params = logging_params
self._logger = setup_component_logger(
logger_name=default_logger.name, **self._logging_params
)
# Don't propagate logs to the root logger, because these logs
# might contain sensitive information. Instead, these logs should
# be confined to the runtime env agent log file `self.LOG_FILENAME`.
self._logger.propagate = False
self._logger.info("Starting runtime env agent at pid %s", os.getpid())
self._logger.info(f"Parent raylet pid is {os.environ.get('RAY_RAYLET_PID')}")
self._runtime_env_dir = runtime_env_dir
self._per_job_logger_cache = dict()
# Cache the results of creating envs to avoid repeatedly calling into
# conda and other slow calls.
self._env_cache: Dict[str, CreatedEnvResult] = dict()
# Maps a serialized runtime env to a lock that is used
# to prevent multiple concurrent installs of the same env.
self._env_locks: Dict[str, asyncio.Lock] = dict()
self._gcs_client = gcs_client
self._pip_plugin = PipPlugin(self._runtime_env_dir)
self._uv_plugin = UvPlugin(self._runtime_env_dir)
self._conda_plugin = CondaPlugin(self._runtime_env_dir)
self._py_modules_plugin = PyModulesPlugin(
self._runtime_env_dir, self._gcs_client
)
self._py_executable_plugin = PyExecutablePlugin()
self._java_jars_plugin = JavaJarsPlugin(self._runtime_env_dir, self._gcs_client)
self._working_dir_plugin = WorkingDirPlugin(
self._runtime_env_dir, self._gcs_client
)
self._container_plugin = ContainerPlugin(temp_dir)
# TODO(jonathan-anyscale): change the plugin to ProfilerPlugin
# and unify with nsight and other profilers.
self._nsight_plugin = NsightPlugin(self._runtime_env_dir)
self._rocprof_sys_plugin = RocProfSysPlugin(self._runtime_env_dir)
self._image_uri_plugin = get_image_uri_plugin_cls()(temp_dir)
# TODO(architkulkarni): "base plugins" and third-party plugins should all go
# through the same code path. We should never need to refer to
# self._xxx_plugin, we should just iterate through self._plugins.
self._base_plugins: List[RuntimeEnvPlugin] = [
self._working_dir_plugin,
self._uv_plugin,
self._pip_plugin,
self._conda_plugin,
self._py_modules_plugin,
self._py_executable_plugin,
self._java_jars_plugin,
self._container_plugin,
self._nsight_plugin,
self._rocprof_sys_plugin,
self._image_uri_plugin,
]
self._plugin_manager = RuntimeEnvPluginManager()
for plugin in self._base_plugins:
self._plugin_manager.add_plugin(plugin)
self._reference_table = ReferenceTable(
self.uris_parser,
self.unused_uris_processor,
self.unused_runtime_env_processor,
)
self._logger.info(
"Listening to address %s, port %d", address, runtime_env_agent_port
)
try:
self._node_ip = ray.util.get_node_ip_address()
self._node_prefix = f"[Node {self._node_ip}] "
except Exception as e:
self._logger.warning(f"Failed to get node IP address, using fallback: {e}")
self._node_prefix = "[Node unknown] "
def uris_parser(self, runtime_env: RuntimeEnv):
result = list()
for name, plugin_setup_context in self._plugin_manager.plugins.items():
plugin = plugin_setup_context.class_instance
uris = plugin.get_uris(runtime_env)
for uri in uris:
result.append((uri, UriType(name)))
return result
def unused_uris_processor(self, unused_uris: List[Tuple[str, UriType]]) -> None:
for uri, uri_type in unused_uris:
self._plugin_manager.plugins[str(uri_type)].uri_cache.mark_unused(uri)
def unused_runtime_env_processor(self, unused_runtime_env: str) -> None:
def delete_runtime_env():
del self._env_cache[unused_runtime_env]
self._logger.info(
"Runtime env %s removed from env-level cache.", unused_runtime_env
)
if unused_runtime_env in self._env_cache:
if not self._env_cache[unused_runtime_env].success:
loop = get_or_create_event_loop()
# Cache the bad runtime env result by ttl seconds.
loop.call_later(
runtime_env_consts.BAD_RUNTIME_ENV_CACHE_TTL_SECONDS,
delete_runtime_env,
)
else:
delete_runtime_env()
def get_or_create_logger(self, job_id: bytes, log_files: List[str]):
job_id = job_id.decode()
if job_id not in self._per_job_logger_cache:
params = self._logging_params.copy()
params["filename"] = [f"runtime_env_setup-{job_id}.log", *log_files]
params["logger_name"] = f"runtime_env_{job_id}"
params["propagate"] = False
per_job_logger = setup_component_logger(**params)
self._per_job_logger_cache[job_id] = per_job_logger
return self._per_job_logger_cache[job_id]
async def GetOrCreateRuntimeEnv(self, request):
self._logger.debug(
f"Got request from {request.source_process} to increase "
"reference for runtime env: "
f"{request.serialized_runtime_env}."
)
async def _setup_runtime_env(
runtime_env: RuntimeEnv,
runtime_env_config: RuntimeEnvConfig,
):
log_files = runtime_env_config.get("log_files", [])
# Use a separate logger for each job.
per_job_logger = self.get_or_create_logger(request.job_id, log_files)
context = RuntimeEnvContext(env_vars=runtime_env.env_vars())
# Warn about unrecognized fields in the runtime env.
for name, _ in runtime_env.plugins():
if name not in self._plugin_manager.plugins:
per_job_logger.warning(
f"runtime_env field {name} is not recognized by "
"Ray and will be ignored. In the future, unrecognized "
"fields in the runtime_env will raise an exception."
)
# Creates each runtime env URI by their priority. `working_dir` is special
# because it needs to be created before other plugins. All other plugins are
# created in the priority order (smaller priority value -> earlier to
# create), with a special environment variable being set to the working dir.
# ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR}
# First create working dir...
working_dir_ctx = self._plugin_manager.plugins[WorkingDirPlugin.name]
await create_for_plugin_if_needed(
runtime_env,
working_dir_ctx.class_instance,
working_dir_ctx.uri_cache,
context,
per_job_logger,
)
# Then within the working dir, create the other plugins.
working_dir_uri_or_none = runtime_env.working_dir_uri()
with self._working_dir_plugin.with_working_dir_env(working_dir_uri_or_none):
"""Run setup for each plugin unless it has already been cached."""
for (
plugin_setup_context
) in self._plugin_manager.sorted_plugin_setup_contexts():
plugin = plugin_setup_context.class_instance
if plugin.name != WorkingDirPlugin.name:
uri_cache = plugin_setup_context.uri_cache
await create_for_plugin_if_needed(
runtime_env, plugin, uri_cache, context, per_job_logger
)
return context
async def _create_runtime_env_with_retry(
runtime_env: RuntimeEnv,
setup_timeout_seconds: int,
runtime_env_config: RuntimeEnvConfig,
) -> Tuple[bool, str, str]:
"""Create runtime env with retry times. This function won't raise exceptions.
Args:
runtime_env: The instance of RuntimeEnv class.
setup_timeout_seconds: The timeout of runtime environment creation for
each attempt.
runtime_env_config: The configuration for the runtime environment.
Returns:
Tuple[bool, str, str]: A tuple containing:
- result (bool): Whether the creation was successful
- runtime_env_context (str): The serialized context if successful, None otherwise
- error_message (str): Error message if failed, None otherwise
"""
self._logger.info(
f"Creating runtime env: {serialized_env} with timeout "
f"{setup_timeout_seconds} seconds."
)
num_retries = runtime_env_consts.RUNTIME_ENV_RETRY_TIMES
error_message = None
serialized_context = None
for i in range(num_retries):
# Only sleep when retrying.
if i != 0:
await asyncio.sleep(
runtime_env_consts.RUNTIME_ENV_RETRY_INTERVAL_MS / 1000
)
try:
runtime_env_setup_task = _setup_runtime_env(
runtime_env, runtime_env_config
)
runtime_env_context = await asyncio.wait_for(
runtime_env_setup_task, timeout=setup_timeout_seconds
)
serialized_context = runtime_env_context.serialize()
error_message = None
break
except Exception as e:
err_msg = f"Failed to create runtime env {serialized_env}."
self._logger.exception(err_msg)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
if isinstance(e, asyncio.TimeoutError):
hint = (
f"Failed to install runtime_env within the "
f"timeout of {setup_timeout_seconds} seconds. Consider "
"increasing the timeout in the runtime_env config. "
"For example: \n"
' runtime_env={"config": {"setup_timeout_seconds":'
" 1800}, ...}\n"
"If not provided, the default timeout is "
f"{DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS} seconds. "
)
error_message = hint + error_message
if error_message:
self._logger.error(
"runtime_env creation failed %d times, giving up.",
num_retries,
)
return False, None, error_message
else:
self._logger.info(
"Successfully created runtime env: %s, context: %s",
serialized_env,
serialized_context,
)
return True, serialized_context, None
try:
serialized_env = request.serialized_runtime_env
runtime_env = RuntimeEnv.deserialize(serialized_env)
except Exception as e:
self._logger.exception(
"[Increase] Failed to parse runtime env: " f"{serialized_env}"
)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
# Increase reference
self._reference_table.increase_reference(
runtime_env, serialized_env, request.source_process
)
if serialized_env not in self._env_locks:
# async lock to prevent the same env being concurrently installed
self._env_locks[serialized_env] = asyncio.Lock()
async with self._env_locks[serialized_env]:
if serialized_env in self._env_cache:
serialized_context = self._env_cache[serialized_env]
result = self._env_cache[serialized_env]
if result.success:
context = result.result
self._logger.info(
"Runtime env already created "
f"successfully. Env: {serialized_env}, "
f"context: {context}"
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK,
serialized_runtime_env_context=context,
)
else:
error_message = result.result
self._logger.info(
"Runtime env already failed. "
f"Env: {serialized_env}, "
f"err: {error_message}"
)
# Recover the reference.
self._reference_table.decrease_reference(
runtime_env, serialized_env, request.source_process
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
if SLEEP_FOR_TESTING_S:
self._logger.info(f"Sleeping for {SLEEP_FOR_TESTING_S}s.")
time.sleep(int(SLEEP_FOR_TESTING_S))
runtime_env_config = RuntimeEnvConfig.from_proto(request.runtime_env_config)
# accroding to the document of `asyncio.wait_for`,
# None means disable timeout logic
setup_timeout_seconds = (
None
if runtime_env_config["setup_timeout_seconds"] == -1
else runtime_env_config["setup_timeout_seconds"]
)
start = time.perf_counter()
(
successful,
serialized_context,
error_message,
) = await _create_runtime_env_with_retry(
runtime_env,
setup_timeout_seconds,
runtime_env_config,
)
creation_time_ms = int(round((time.perf_counter() - start) * 1000, 0))
if not successful:
# Recover the reference.
self._reference_table.decrease_reference(
runtime_env, serialized_env, request.source_process
)
# Add the result to env cache.
self._env_cache[serialized_env] = CreatedEnvResult(
successful,
serialized_context if successful else error_message,
creation_time_ms,
)
# Reply the RPC
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK
if successful
else runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
serialized_runtime_env_context=serialized_context,
error_message=f"{self._node_prefix}{error_message}"
if not successful
else "",
)
async def DeleteRuntimeEnvIfPossible(self, request):
self._logger.info(
f"Got request from {request.source_process} to decrease "
"reference for runtime env: "
f"{request.serialized_runtime_env}."
)
try:
runtime_env = RuntimeEnv.deserialize(request.serialized_runtime_env)
except Exception as e:
self._logger.exception(
"[Decrease] Failed to parse runtime env: "
f"{request.serialized_runtime_env}"
)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
try:
self._reference_table.decrease_reference(
runtime_env, request.serialized_runtime_env, request.source_process
)
except Exception as e:
return runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}Failed to decrement reference for runtime env for {str(e)}",
)
return runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK
)
async def GetRuntimeEnvsInfo(self, request):
"""Return the runtime env information of the node."""
# TODO(sang): Currently, it only includes runtime_env information.
# We should include the URI information which includes,
# URIs
# Caller
# Ref counts
# Cache information
# Metrics (creation time & success)
# Deleted URIs
limit = request.limit if request.HasField("limit") else -1
runtime_env_states = defaultdict(ProtoRuntimeEnvState)
runtime_env_refs = self._reference_table.runtime_env_refs
for runtime_env, ref_cnt in runtime_env_refs.items():
runtime_env_states[runtime_env].runtime_env = runtime_env
runtime_env_states[runtime_env].ref_cnt = ref_cnt
for runtime_env, result in self._env_cache.items():
runtime_env_states[runtime_env].runtime_env = runtime_env
runtime_env_states[runtime_env].success = result.success
if not result.success:
runtime_env_states[runtime_env].error = result.result
runtime_env_states[runtime_env].creation_time_ms = result.creation_time_ms
reply = runtime_env_agent_pb2.GetRuntimeEnvsInfoReply()
count = 0
for runtime_env_state in runtime_env_states.values():
if limit != -1 and count >= limit:
break
count += 1
reply.runtime_env_states.append(runtime_env_state)
reply.total = len(runtime_env_states)
return reply
@@ -0,0 +1,20 @@
import ray._private.ray_constants as ray_constants
RUNTIME_ENV_RETRY_TIMES = ray_constants.env_integer("RUNTIME_ENV_RETRY_TIMES", 3)
RUNTIME_ENV_RETRY_INTERVAL_MS = ray_constants.env_integer(
"RUNTIME_ENV_RETRY_INTERVAL_MS", 1000
)
# Cache TTL for bad runtime env. After this time, delete the cache and retry to create
# runtime env if needed.
BAD_RUNTIME_ENV_CACHE_TTL_SECONDS = ray_constants.env_integer(
"BAD_RUNTIME_ENV_CACHE_TTL_SECONDS", 60 * 10
)
RUNTIME_ENV_LOG_FILENAME = "runtime_env.log"
RUNTIME_ENV_AGENT_PORT_PREFIX = "RUNTIME_ENV_AGENT_PORT_PREFIX:"
RUNTIME_ENV_AGENT_LOG_FILENAME = "runtime_env_agent.log"
RUNTIME_ENV_AGENT_CHECK_PARENT_INTERVAL_S_ENV_NAME = (
"RAY_RUNTIME_ENV_AGENT_CHECK_PARENT_INTERVAL_S" # noqa
)
+400
View File
@@ -0,0 +1,400 @@
import hashlib
import json
import logging
import os
import runpy
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from filelock import FileLock
import ray
from ray._common.utils import (
get_or_create_event_loop,
try_to_create_directory,
)
from ray._private.runtime_env.conda_utils import (
create_conda_env_if_needed,
delete_conda_env,
get_conda_activate_commands,
get_conda_envs,
get_conda_info_json,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.validation import parse_and_validate_conda
from ray._private.utils import (
get_directory_size_bytes,
get_master_wheel_url,
get_release_wheel_url,
get_wheel_filename,
)
default_logger = logging.getLogger(__name__)
_WIN32 = os.name == "nt"
def _resolve_current_ray_path() -> str:
# When ray is built from source with pip install -e,
# ray.__file__ returns .../python/ray/__init__.py and this function returns
# ".../python".
# When ray is installed from a prebuilt binary, ray.__file__ returns
# .../site-packages/ray/__init__.py and this function returns
# ".../site-packages".
return os.path.split(os.path.split(ray.__file__)[0])[0]
def _get_ray_setup_spec():
"""Find the Ray setup_spec from the currently running Ray.
This function works even when Ray is built from source with pip install -e.
"""
ray_source_python_path = _resolve_current_ray_path()
setup_py_path = os.path.join(ray_source_python_path, "setup.py")
return runpy.run_path(setup_py_path)["setup_spec"]
def _resolve_install_from_source_ray_dependencies():
"""Find the Ray dependencies when Ray is installed from source."""
deps = (
_get_ray_setup_spec().install_requires + _get_ray_setup_spec().extras["default"]
)
# Remove duplicates
return list(set(deps))
def _inject_ray_to_conda_site(
conda_path, logger: Optional[logging.Logger] = default_logger
):
"""Write the current Ray site package directory to a new site"""
if _WIN32:
python_binary = os.path.join(conda_path, "python")
else:
python_binary = os.path.join(conda_path, "bin/python")
site_packages_path = (
subprocess.check_output(
[
python_binary,
"-c",
"import sysconfig; print(sysconfig.get_paths()['purelib'])",
]
)
.decode()
.strip()
)
ray_path = _resolve_current_ray_path()
logger.warning(
f"Injecting {ray_path} to environment site-packages {site_packages_path} "
"because _inject_current_ray flag is on."
)
maybe_ray_dir = os.path.join(site_packages_path, "ray")
if os.path.isdir(maybe_ray_dir):
logger.warning(f"Replacing existing ray installation with {ray_path}")
shutil.rmtree(maybe_ray_dir)
# See usage of *.pth file at
# https://docs.python.org/3/library/site.html
with open(os.path.join(site_packages_path, "ray_shared.pth"), "w") as f:
f.write(ray_path)
def _current_py_version():
return ".".join(map(str, sys.version_info[:3])) # like 3.6.10
def current_ray_pip_specifier(
logger: Optional[logging.Logger] = default_logger,
) -> Optional[str]:
"""The pip requirement specifier for the running version of Ray.
Args:
logger: Logger used to warn when the running Ray version cannot be
detected (e.g. when running a source build).
Returns:
A string which can be passed to `pip install` to install the
currently running Ray version, or None if running on a version
built from source locally (likely if you are developing Ray).
Examples:
Returns "https://s3-us-west-2.amazonaws.com/ray-wheels/[..].whl"
if running a stable release, a nightly or a specific commit
"""
if os.environ.get("RAY_CI_POST_WHEEL_TESTS"):
# Running in Buildkite CI after the wheel has been built.
# Wheels are at in the ray/.whl directory, but use relative path to
# allow for testing locally if needed.
return os.path.join(
Path(ray.__file__).resolve().parents[2], ".whl", get_wheel_filename()
)
elif ray.__commit__ == "{{RAY_COMMIT_SHA}}":
# Running on a version built from source locally.
if os.environ.get("RAY_RUNTIME_ENV_LOCAL_DEV_MODE") != "1":
logger.warning(
"Current Ray version could not be detected, most likely "
"because you have manually built Ray from source. To use "
"runtime_env in this case, set the environment variable "
"RAY_RUNTIME_ENV_LOCAL_DEV_MODE=1."
)
return None
elif "dev" in ray.__version__:
# Running on a nightly wheel.
return get_master_wheel_url()
else:
return get_release_wheel_url()
def inject_dependencies(
conda_dict: Dict[Any, Any],
py_version: str,
pip_dependencies: Optional[List[str]] = None,
) -> Dict[Any, Any]:
"""Add Ray, Python and (optionally) extra pip dependencies to a conda dict.
Args:
conda_dict: A dict representing the JSON-serialized conda
environment YAML file. This dict will be modified and returned.
py_version: A string representing a Python version to inject
into the conda dependencies, e.g. "3.7.7"
pip_dependencies: A list of pip dependencies that
will be prepended to the list of pip dependencies in
the conda dict. If the conda dict does not already have a "pip"
field, one will be created.
Returns:
The modified dict. (Note: the input argument conda_dict is modified
and returned.)
"""
if pip_dependencies is None:
pip_dependencies = []
if conda_dict.get("dependencies") is None:
conda_dict["dependencies"] = []
# Inject Python dependency.
deps = conda_dict["dependencies"]
# Add current python dependency. If the user has already included a
# python version dependency, conda will raise a readable error if the two
# are incompatible, e.g:
# ResolvePackageNotFound: - python[version='3.5.*,>=3.6']
deps.append(f"python={py_version}")
if "pip" not in deps:
deps.append("pip")
# Insert pip dependencies.
found_pip_dict = False
for dep in deps:
if isinstance(dep, dict) and dep.get("pip") and isinstance(dep["pip"], list):
dep["pip"] = pip_dependencies + dep["pip"]
found_pip_dict = True
break
if not found_pip_dict:
deps.append({"pip": pip_dependencies})
return conda_dict
def _get_conda_env_hash(conda_dict: Dict) -> str:
# Set `sort_keys=True` so that different orderings yield the same hash.
serialized_conda_spec = json.dumps(conda_dict, sort_keys=True)
hash = hashlib.sha1(serialized_conda_spec.encode("utf-8")).hexdigest()
return hash
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"conda://<hashed_dependencies>"`, or None if no GC required."""
conda = runtime_env.get("conda")
if conda is not None:
if isinstance(conda, str):
# User-preinstalled conda env. We don't garbage collect these, so
# we don't track them with URIs.
uri = None
elif isinstance(conda, dict):
uri = f"conda://{_get_conda_env_hash(conda_dict=conda)}"
else:
raise TypeError(
"conda field received by RuntimeEnvAgent must be "
f"str or dict, not {type(conda).__name__}."
)
else:
uri = None
return uri
def _get_conda_dict_with_ray_inserted(
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> Dict[str, Any]:
"""Returns the conda spec with the Ray and `python` dependency inserted."""
conda_dict = json.loads(runtime_env.conda_config())
assert conda_dict is not None
ray_pip = current_ray_pip_specifier(logger=logger)
if ray_pip:
extra_pip_dependencies = [ray_pip, "ray[default]"]
elif runtime_env.get_extension("_inject_current_ray"):
extra_pip_dependencies = _resolve_install_from_source_ray_dependencies()
else:
extra_pip_dependencies = []
conda_dict = inject_dependencies(
conda_dict, _current_py_version(), extra_pip_dependencies
)
return conda_dict
class CondaPlugin(RuntimeEnvPlugin):
name = "conda"
def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "conda")
try_to_create_directory(self._resources_dir)
# It is not safe for multiple processes to install conda envs
# concurrently, even if the envs are different, so use a global
# lock for all conda installs and deletions.
# See https://github.com/ray-project/ray/issues/17086
self._installs_and_deletions_file_lock = os.path.join(
self._resources_dir, "ray-conda-installs-and-deletions.lock"
)
# A set of named conda environments (instead of yaml or dict)
# that are validated to exist.
# NOTE: It has to be only used within the same thread, which
# is an event loop.
# Also, we don't need to GC this field because it is pretty small.
self._validated_named_conda_env = set()
def _get_path_from_hash(self, hash: str) -> str:
"""Generate a path from the hash of a conda or pip spec.
The output path also functions as the name of the conda environment
when using the `--prefix` option to `conda create` and `conda remove`.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/conda/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._resources_dir, hash)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the conda URI from the RuntimeEnv if it exists, else return []."""
conda_uri = runtime_env.conda_uri()
if conda_uri:
return [conda_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info(f"Got request to delete URI {uri}")
protocol, hash = parse_uri(uri)
if protocol != Protocol.CONDA:
raise ValueError(
"CondaPlugin can only delete URIs with protocol "
f"conda. Received protocol {protocol}, URI {uri}"
)
conda_env_path = self._get_path_from_hash(hash)
local_dir_size = get_directory_size_bytes(conda_env_path)
with FileLock(self._installs_and_deletions_file_lock):
successful = delete_conda_env(prefix=conda_env_path, logger=logger)
if not successful:
logger.warning(f"Error when deleting conda env {conda_env_path}. ")
return 0
return local_dir_size
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
if not runtime_env.has_conda():
return 0
def _create():
result = parse_and_validate_conda(runtime_env.get("conda"))
if isinstance(result, str):
# The conda env name is given.
# In this case, we only verify if the given
# conda env exists.
# If the env is already validated, do nothing.
if result in self._validated_named_conda_env:
return 0
conda_info = get_conda_info_json()
envs = get_conda_envs(conda_info)
# We accept `result` as a conda name or full path.
if not any(result == env[0] or result == env[1] for env in envs):
raise ValueError(
f"The given conda environment '{result}' "
f"from the runtime env {runtime_env} doesn't "
"exist from the output of `conda info --json`. "
"You can only specify an env that already exists. "
f"Please make sure to create an env {result} "
)
self._validated_named_conda_env.add(result)
return 0
logger.debug(
"Setting up conda for runtime_env: " f"{runtime_env.serialize()}"
)
protocol, hash = parse_uri(uri)
conda_env_name = self._get_path_from_hash(hash)
conda_dict = _get_conda_dict_with_ray_inserted(runtime_env, logger=logger)
logger.info(f"Setting up conda environment with {runtime_env}")
with FileLock(self._installs_and_deletions_file_lock):
try:
conda_yaml_file = os.path.join(
self._resources_dir, "environment.yml"
)
with open(conda_yaml_file, "w") as file:
yaml.dump(conda_dict, file)
create_conda_env_if_needed(
conda_yaml_file, prefix=conda_env_name, logger=logger
)
finally:
os.remove(conda_yaml_file)
if runtime_env.get_extension("_inject_current_ray"):
_inject_ray_to_conda_site(conda_path=conda_env_name, logger=logger)
logger.info(f"Finished creating conda environment at {conda_env_name}")
return get_directory_size_bytes(conda_env_name)
loop = get_or_create_event_loop()
return await loop.run_in_executor(None, _create)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.has_conda():
return
if runtime_env.conda_env_name():
conda_env_name = runtime_env.conda_env_name()
else:
protocol, hash = parse_uri(runtime_env.conda_uri())
conda_env_name = self._get_path_from_hash(hash)
context.py_executable = "python"
context.command_prefix += get_conda_activate_commands(conda_env_name)
@@ -0,0 +1,285 @@
import hashlib
import json
import logging
import os
import shutil
import subprocess
from typing import List, Optional, Tuple, Union
"""Utilities for conda. Adapted from https://github.com/mlflow/mlflow."""
# Name of environment variable indicating a path to a conda installation. Ray
# will default to running "conda" if unset.
RAY_CONDA_HOME = "RAY_CONDA_HOME"
_WIN32 = os.name == "nt"
def get_conda_activate_commands(conda_env_name: str) -> List[str]:
"""
Get a list of commands to run to silently activate the given conda env.
"""
# Checking for newer conda versions
if not _WIN32 and ("CONDA_EXE" in os.environ or RAY_CONDA_HOME in os.environ):
conda_path = get_conda_bin_executable("conda")
activate_conda_env = [
".",
f"{os.path.dirname(conda_path)}/../etc/profile.d/conda.sh",
"&&",
]
activate_conda_env += ["conda", "activate", conda_env_name]
else:
activate_path = get_conda_bin_executable("activate")
if not _WIN32:
# Use bash command syntax
activate_conda_env = ["source", activate_path, conda_env_name]
else:
conda_path = get_conda_bin_executable("conda")
activate_conda_env = [conda_path, "activate", conda_env_name]
return activate_conda_env + ["1>&2", "&&"]
def get_conda_bin_executable(executable_name: str) -> str:
"""
Return path to the specified executable, assumed to be discoverable within
a conda installation.
The conda home directory (expected to contain a 'bin' subdirectory on
linux) is configurable via the ``RAY_CONDA_HOME`` environment variable. If
``RAY_CONDA_HOME`` is unspecified, try the ``CONDA_EXE`` environment
variable set by activating conda. If neither is specified, this method
returns `executable_name`.
"""
conda_home = os.environ.get(RAY_CONDA_HOME)
if conda_home:
if _WIN32:
candidate = os.path.join(conda_home, "%s.exe" % executable_name)
if os.path.exists(candidate):
return candidate
candidate = os.path.join(conda_home, "%s.bat" % executable_name)
if os.path.exists(candidate):
return candidate
else:
return os.path.join(conda_home, "bin/%s" % executable_name)
else:
conda_home = "."
# Use CONDA_EXE as per https://github.com/conda/conda/issues/7126
if "CONDA_EXE" in os.environ:
conda_bin_dir = os.path.dirname(os.environ["CONDA_EXE"])
if _WIN32:
candidate = os.path.join(conda_home, "%s.exe" % executable_name)
if os.path.exists(candidate):
return candidate
candidate = os.path.join(conda_home, "%s.bat" % executable_name)
if os.path.exists(candidate):
return candidate
else:
return os.path.join(conda_bin_dir, executable_name)
if _WIN32:
return executable_name + ".bat"
return executable_name
def _get_conda_env_name(conda_env_path: str) -> str:
conda_env_contents = open(conda_env_path).read()
return "ray-%s" % hashlib.sha1(conda_env_contents.encode("utf-8")).hexdigest()
def create_conda_env_if_needed(
conda_yaml_file: str, prefix: str, logger: Optional[logging.Logger] = None
) -> None:
"""
Given a conda YAML, creates a conda environment containing the required
dependencies if such a conda environment doesn't already exist.
Args:
conda_yaml_file: The path to a conda `environment.yml` file.
prefix: Directory to install the environment into via
the `--prefix` option to conda create. This also becomes the name
of the conda env; i.e. it can be passed into `conda activate` and
`conda remove`
logger: Logger used to surface progress and errors; defaults to the
module logger when not provided.
"""
if logger is None:
logger = logging.getLogger(__name__)
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except (EnvironmentError, FileNotFoundError):
raise ValueError(
f"Could not find Conda executable at '{conda_path}'. "
"Ensure Conda is installed as per the instructions at "
"https://conda.io/projects/conda/en/latest/"
"user-guide/install/index.html. "
"You can also configure Ray to look for a specific "
f"Conda executable by setting the {RAY_CONDA_HOME} "
"environment variable to the path of the Conda executable."
)
_, stdout, _ = exec_cmd([conda_path, "env", "list", "--json"])
envs = json.loads(stdout[stdout.index("{") :])["envs"]
if prefix in envs:
logger.info(f"Conda environment {prefix} already exists.")
return
create_cmd = [
conda_path,
"env",
"create",
"--file",
conda_yaml_file,
"--prefix",
prefix,
]
logger.info(f"Creating conda environment {prefix}")
exit_code, output = exec_cmd_stream_to_logger(create_cmd, logger)
if exit_code != 0:
if os.path.exists(prefix):
shutil.rmtree(prefix)
raise RuntimeError(
f"Failed to install conda environment {prefix}:\nOutput:\n{output}"
)
def delete_conda_env(prefix: str, logger: Optional[logging.Logger] = None) -> bool:
if logger is None:
logger = logging.getLogger(__name__)
logger.info(f"Deleting conda environment {prefix}")
conda_path = get_conda_bin_executable("conda")
delete_cmd = [conda_path, "remove", "-p", prefix, "--all", "-y"]
exit_code, output = exec_cmd_stream_to_logger(delete_cmd, logger)
if exit_code != 0:
logger.debug(f"Failed to delete conda environment {prefix}:\n{output}")
return False
return True
def get_conda_env_list() -> list:
"""
Get conda env list in full paths.
"""
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except EnvironmentError:
raise ValueError(f"Could not find Conda executable at {conda_path}.")
_, stdout, _ = exec_cmd([conda_path, "env", "list", "--json"])
envs = json.loads(stdout)["envs"]
return envs
def get_conda_info_json() -> dict:
"""
Get `conda info --json` output.
Returns dict of conda info. See [1] for more details. We mostly care about these
keys:
- `conda_prefix`: str The path to the conda installation.
- `envs`: List[str] absolute paths to conda environments.
[1] https://github.com/conda/conda/blob/main/conda/cli/main_info.py
"""
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except EnvironmentError:
raise ValueError(f"Could not find Conda executable at {conda_path}.")
_, stdout, _ = exec_cmd([conda_path, "info", "--json"])
return json.loads(stdout)
def get_conda_envs(conda_info: dict) -> List[Tuple[str, str]]:
"""
Gets the conda environments, as a list of (name, path) tuples.
"""
prefix = conda_info["conda_prefix"]
ret = []
for env in conda_info["envs"]:
if env == prefix:
ret.append(("base", env))
else:
ret.append((os.path.basename(env), env))
return ret
class ShellCommandException(Exception):
pass
def exec_cmd(
cmd: List[str], throw_on_error: bool = True, logger: Optional[logging.Logger] = None
) -> Union[int, Tuple[int, str, str]]:
"""
Runs a command as a child process.
A convenience wrapper for running a command from a Python script.
Note on the return value: A tuple of the exit code,
standard output and standard error is returned.
Args:
cmd: the command to run, as a list of strings
throw_on_error: if true, raises an Exception if the exit code of the
program is nonzero
logger: Unused; retained for API compatibility.
Returns:
A tuple of (exit_code, stdout, stderr) from the child process.
"""
child = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
(stdout, stderr) = child.communicate()
exit_code = child.wait()
if throw_on_error and exit_code != 0:
raise ShellCommandException(
"Non-zero exit code: %s\n\nSTDOUT:\n%s\n\nSTDERR:%s"
% (exit_code, stdout, stderr)
)
return exit_code, stdout, stderr
def exec_cmd_stream_to_logger(
cmd: List[str], logger: logging.Logger, n_lines: int = 50, **kwargs
) -> Tuple[int, str]:
"""Runs a command as a child process, streaming output to the logger.
The last n_lines lines of output are also returned (stdout and stderr).
"""
if "env" in kwargs and _WIN32 and "PATH" not in [x.upper() for x in kwargs.keys]:
raise ValueError("On windows, Popen requires 'PATH' in 'env'")
child = subprocess.Popen(
cmd,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
**kwargs,
)
last_n_lines = []
with child.stdout:
for line in iter(child.stdout.readline, b""):
exit_code = child.poll()
if exit_code is not None:
break
line = line.strip()
if not line:
continue
last_n_lines.append(line.strip())
last_n_lines = last_n_lines[-n_lines:]
logger.info(line.strip())
exit_code = child.wait()
return exit_code, "\n".join(last_n_lines)
@@ -0,0 +1,28 @@
# Env var set by job manager to pass runtime env and metadata to subprocess
RAY_JOB_CONFIG_JSON_ENV_VAR = "RAY_JOB_CONFIG_JSON_ENV_VAR"
# The plugin config which should be loaded when ray cluster starts.
# It is a json formatted config,
# e.g. [{"class": "xxx.xxx.xxx_plugin", "priority": 10}].
RAY_RUNTIME_ENV_PLUGINS_ENV_VAR = "RAY_RUNTIME_ENV_PLUGINS"
# The field name of plugin class in the plugin config.
RAY_RUNTIME_ENV_CLASS_FIELD_NAME = "class"
# The field name of priority in the plugin config.
RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME = "priority"
# The default priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY = 10
# The minimum priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY = 0
# The maximum priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY = 100
# The schema files or directories of plugins which should be loaded in workers.
RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR = "RAY_RUNTIME_ENV_PLUGIN_SCHEMAS"
# The file suffix of runtime env plugin schemas.
RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX = ".json"
+108
View File
@@ -0,0 +1,108 @@
import json
import logging
import os
import shlex
import subprocess
import sys
from typing import Dict, List, Optional
from ray._private.services import get_ray_jars_dir
from ray._private.utils import update_envs
from ray.core.generated.common_pb2 import Language
from ray.util.annotations import DeveloperAPI
logger = logging.getLogger(__name__)
@DeveloperAPI
class RuntimeEnvContext:
"""A context used to describe the created runtime env."""
def __init__(
self,
command_prefix: List[str] = None,
env_vars: Dict[str, str] = None,
py_executable: Optional[str] = None,
override_worker_entrypoint: Optional[str] = None,
java_jars: List[str] = None,
):
self.command_prefix = command_prefix or []
self.env_vars = env_vars or {}
self.py_executable = py_executable or sys.executable
self.override_worker_entrypoint: Optional[str] = override_worker_entrypoint
self.java_jars = java_jars or []
def serialize(self) -> str:
return json.dumps(self.__dict__)
@staticmethod
def deserialize(json_string):
return RuntimeEnvContext(**json.loads(json_string))
def exec_worker(self, passthrough_args: List[str], language: Language):
update_envs(self.env_vars)
if language == Language.PYTHON and sys.platform == "win32":
executable = [self.py_executable]
elif language == Language.PYTHON:
executable = ["exec", self.py_executable]
elif language == Language.JAVA:
executable = ["java"]
ray_jars = os.path.join(get_ray_jars_dir(), "*")
local_java_jars = []
for java_jar in self.java_jars:
local_java_jars.append(f"{java_jar}/*")
local_java_jars.append(java_jar)
class_path_args = ["-cp", ray_jars + ":" + str(":".join(local_java_jars))]
passthrough_args = class_path_args + passthrough_args
elif sys.platform == "win32":
executable = []
else:
executable = ["exec"]
# By default, raylet uses the path to default_worker.py on host.
# However, the path to default_worker.py inside the container
# can be different. We need the user to specify the path to
# default_worker.py inside the container.
if self.override_worker_entrypoint:
logger.debug(
f"Changing the worker entrypoint from {passthrough_args[0]} to "
f"{self.override_worker_entrypoint}."
)
passthrough_args[0] = self.override_worker_entrypoint
if sys.platform == "win32":
def quote(s):
s = s.replace("&", "%26")
return s
passthrough_args = [quote(s) for s in passthrough_args]
cmd = [*self.command_prefix, *executable, *passthrough_args]
logger.debug(f"Exec'ing worker with command: {cmd}")
subprocess.Popen(cmd, shell=True).wait()
else:
# We use shlex to do the necessary shell escape
# of special characters in passthrough_args.
passthrough_args = [shlex.quote(s) for s in passthrough_args]
cmd = [*self.command_prefix, *executable, *passthrough_args]
# TODO(SongGuyang): We add this env to command for macOS because it doesn't
# work for the C++ process of `os.execvp`. We should find a better way to
# fix it.
MACOS_LIBRARY_PATH_ENV_NAME = "DYLD_LIBRARY_PATH"
if MACOS_LIBRARY_PATH_ENV_NAME in os.environ:
cmd.insert(
0,
f"{MACOS_LIBRARY_PATH_ENV_NAME}="
f"{os.environ[MACOS_LIBRARY_PATH_ENV_NAME]}",
)
logger.debug(f"Exec'ing worker with command: {cmd}")
# PyCharm will monkey patch the os.execvp at
# .pycharm_helpers/pydev/_pydev_bundle/pydev_monkey.py
# The monkey patched os.execvp function has a different
# signature. So, we use os.execvp("executable", args=[])
# instead of os.execvp(file="executable", args=[])
os.execvp("bash", args=["bash", "-c", " ".join(cmd)])
@@ -0,0 +1,5 @@
from ray._private.runtime_env.image_uri import ImageURIPlugin
def get_image_uri_plugin_cls():
return ImageURIPlugin
@@ -0,0 +1,118 @@
"""Util functions to manage dependency requirements."""
import logging
import os
import tempfile
from contextlib import asynccontextmanager
from typing import List, Optional, Tuple
from ray._private.runtime_env import virtualenv_utils
from ray._private.runtime_env.utils import check_output_cmd
INTERNAL_PIP_FILENAME = "ray_runtime_env_internal_pip_requirements.txt"
MAX_INTERNAL_PIP_FILENAME_TRIES = 100
def gen_requirements_txt(requirements_file: str, pip_packages: List[str]):
"""Dump [pip_packages] to the given [requirements_file] for later env setup."""
with open(requirements_file, "w") as file:
for line in pip_packages:
file.write(line + "\n")
@asynccontextmanager
async def check_ray(python: str, cwd: str, logger: logging.Logger):
"""A context manager to check ray is not overwritten.
Currently, we only check ray version and path. It works for virtualenv,
- ray is in Python's site-packages.
- ray is overwritten during yield.
- ray is in virtualenv's site-packages.
"""
async def _get_ray_version_and_path() -> Tuple[str, str]:
with tempfile.TemporaryDirectory(
prefix="check_ray_version_tempfile"
) as tmp_dir:
ray_version_path = os.path.join(tmp_dir, "ray_version.txt")
check_ray_cmd = [
python,
"-c",
"""
import ray
with open(r"{ray_version_path}", "wt") as f:
f.write(ray.__version__)
f.write(" ")
f.write(ray.__path__[0])
""".format(
ray_version_path=ray_version_path
),
]
if virtualenv_utils._WIN32:
env = os.environ.copy()
else:
env = {}
output = await check_output_cmd(
check_ray_cmd, logger=logger, cwd=cwd, env=env
)
logger.info(f"try to write ray version information in: {ray_version_path}")
with open(ray_version_path, "rt") as f:
output = f.read()
# print after import ray may have  endings, so we strip them by *_
ray_version, ray_path, *_ = [s.strip() for s in output.split()]
return ray_version, ray_path
version, path = await _get_ray_version_and_path()
yield
actual_version, actual_path = await _get_ray_version_and_path()
if actual_version != version:
raise RuntimeError(
"Changing the ray version is not allowed: \n"
f" current version: {actual_version}, "
f" expect version: {version}, "
f" current path: {actual_path}, "
f" expect path: {path}, "
"Please ensure the dependencies in the runtime_env pip field "
"do not install a different version of Ray."
)
if actual_path != path:
logger.info(
f"Detected new Ray package with the same version at {actual_path} (vs system {path})."
)
def get_requirements_file(target_dir: str, pip_list: Optional[List[str]]) -> str:
"""Returns the path to the requirements file to use for this runtime env.
If pip_list is not None, we will check if the internal pip filename is in any of
the entries of pip_list. If so, we will append numbers to the end of the
filename until we find one that doesn't conflict. This prevents infinite
recursion if the user specifies the internal pip filename in their pip list.
Args:
target_dir: The directory to store the requirements file in.
pip_list: A list of pip requirements specified by the user.
Returns:
The path to the requirements file to use for this runtime env.
"""
def filename_in_pip_list(filename: str) -> bool:
for pip_entry in pip_list:
if filename in pip_entry:
return True
return False
filename = INTERNAL_PIP_FILENAME
if pip_list is not None:
i = 1
while filename_in_pip_list(filename) and i < MAX_INTERNAL_PIP_FILENAME_TRIES:
filename = f"{INTERNAL_PIP_FILENAME}.{i}"
i += 1
if i == MAX_INTERNAL_PIP_FILENAME_TRIES:
raise RuntimeError(
"Could not find a valid filename for the internal "
"pip requirements file. Please specify a different "
"pip list in your runtime env."
)
return os.path.join(target_dir, filename)
@@ -0,0 +1,229 @@
import asyncio
import logging
import os
import tempfile
from typing import List, Optional
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
default_logger = logging.getLogger(__name__)
async def _create_impl(image_uri: str, logger: logging.Logger):
# Pull image if it doesn't exist
# Also get path to `default_worker.py` inside the image.
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o777)
result_file = os.path.join(tmpdir, "worker_path.txt")
get_worker_path_script = """
import ray._private.workers.default_worker as dw
with open('/shared/worker_path.txt', 'w') as f:
f.write(dw.__file__)
"""
cmd = [
"podman",
"run",
"--rm",
"-v",
f"{tmpdir}:/shared:Z",
image_uri,
"python",
"-c",
get_worker_path_script,
]
logger.info("Pulling image %s", image_uri)
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError(
f"Podman command failed: cmd={cmd}, returncode={process.returncode}, stdout={stdout.decode()}, stderr={stderr.decode()}"
)
if not os.path.exists(result_file):
raise FileNotFoundError(
f"Worker path file not created when getting worker path for image {image_uri}"
)
with open(result_file, "r") as f:
worker_path = f.read().strip()
if not worker_path.endswith(".py"):
raise ValueError(
f"Invalid worker path inferred in image {image_uri}: {worker_path}"
)
logger.info(f"Inferred worker path in image {image_uri}: {worker_path}")
return worker_path
def _modify_context_impl(
image_uri: str,
worker_path: str,
run_options: Optional[List[str]],
context: RuntimeEnvContext,
logger: logging.Logger,
ray_tmp_dir: str,
):
context.override_worker_entrypoint = worker_path
container_driver = "podman"
container_command = [
container_driver,
"run",
"-v",
ray_tmp_dir + ":" + ray_tmp_dir,
"--cgroup-manager=cgroupfs",
"--network=host",
"--pid=host",
"--ipc=host",
# NOTE(zcin): Mounted volumes in rootless containers are
# owned by the user `root`. The user on host (which will
# usually be `ray` if this is being run in a ray docker
# image) who started the container is mapped using user
# namespaces to the user `root` in a rootless container. In
# order for the Ray Python worker to access the mounted ray
# tmp dir, we need to use keep-id mode which maps the user
# as itself (instead of as `root`) into the container.
# https://www.redhat.com/sysadmin/rootless-podman-user-namespace-modes
"--userns=keep-id",
]
# Environment variables to set in container
env_vars = dict()
# Propagate all host environment variables that have the prefix "RAY_"
# This should include RAY_RAYLET_PID
for env_var_name, env_var_value in os.environ.items():
if env_var_name.startswith("RAY_"):
env_vars[env_var_name] = env_var_value
# Support for runtime_env['env_vars']
env_vars.update(context.env_vars)
# Set environment variables
for env_var_name, env_var_value in env_vars.items():
container_command.append("--env")
container_command.append(f"{env_var_name}='{env_var_value}'")
# The RAY_JOB_ID environment variable is needed for the default worker.
# It won't be set at the time setup() is called, but it will be set
# when worker command is executed, so we use RAY_JOB_ID=$RAY_JOB_ID
# for the container start command
container_command.append("--env")
container_command.append("RAY_JOB_ID=$RAY_JOB_ID")
if run_options:
container_command.extend(run_options)
# TODO(chenk008): add resource limit
container_command.append("--entrypoint")
container_command.append("python")
container_command.append(image_uri)
# Example:
# podman run -v /tmp/ray:/tmp/ray
# --cgroup-manager=cgroupfs --network=host --pid=host --ipc=host
# --userns=keep-id --env RAY_RAYLET_PID=23478 --env RAY_JOB_ID=$RAY_JOB_ID
# --entrypoint python rayproject/ray:nightly-py39
container_command_str = " ".join(container_command)
logger.info(f"Starting worker in container with prefix {container_command_str}")
context.py_executable = container_command_str
class ImageURIPlugin(RuntimeEnvPlugin):
"""Starts worker in a container of a custom image."""
name = "image_uri"
@staticmethod
def get_compatible_keys():
return {"image_uri", "config", "env_vars"}
def __init__(self, ray_tmp_dir: str):
self._ray_tmp_dir = ray_tmp_dir
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
if not runtime_env.image_uri():
return
self.worker_path = await _create_impl(runtime_env.image_uri(), logger)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.image_uri():
return
_modify_context_impl(
runtime_env.image_uri(),
self.worker_path,
[],
context,
logger,
self._ray_tmp_dir,
)
class ContainerPlugin(RuntimeEnvPlugin):
"""Starts worker in container."""
name = "container"
def __init__(self, ray_tmp_dir: str):
self._ray_tmp_dir = ray_tmp_dir
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
if not runtime_env.has_py_container() or not runtime_env.py_container_image():
return
self.worker_path = await _create_impl(runtime_env.py_container_image(), logger)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.has_py_container() or not runtime_env.py_container_image():
return
if runtime_env.py_container_worker_path():
logger.warning(
"You are using `container.worker_path`, but the path to "
"`default_worker.py` is now automatically detected from the image. "
"`container.worker_path` is deprecated and will be removed in future "
"versions."
)
_modify_context_impl(
runtime_env.py_container_image(),
runtime_env.py_container_worker_path() or self.worker_path,
runtime_env.py_container_run_options(),
context,
logger,
self._ray_tmp_dir,
)
@@ -0,0 +1,104 @@
import logging
import os
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import (
delete_package,
download_and_unpack_package,
get_local_dir_from_uri,
is_jar_uri,
)
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.utils import get_directory_size_bytes
from ray._raylet import GcsClient
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
class JavaJarsPlugin(RuntimeEnvPlugin):
name = "java_jars"
def __init__(self, resources_dir: str, gcs_client: GcsClient):
self._resources_dir = os.path.join(resources_dir, "java_jars_files")
self._gcs_client = gcs_client
try_to_create_directory(self._resources_dir)
def _get_local_dir_from_uri(self, uri: str):
return get_local_dir_from_uri(uri, self._resources_dir)
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
local_dir_size = get_directory_size_bytes(local_dir)
deleted = delete_package(uri, self._resources_dir)
if not deleted:
logger.warning(f"Tried to delete nonexistent URI: {uri}.")
return 0
return local_dir_size
def get_uris(self, runtime_env: dict) -> List[str]:
return runtime_env.java_jars()
async def _download_jars(
self, uri: str, logger: Optional[logging.Logger] = default_logger
):
"""Download a jar URI."""
try:
jar_file = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
except Exception as e:
raise RuntimeEnvSetupError(
"Failed to download jar file: {}".format(e)
) from e
module_dir = self._get_local_dir_from_uri(uri)
logger.debug(f"Succeeded to download jar file {jar_file} .")
return module_dir
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not uri:
return 0
if is_jar_uri(uri):
module_dir = await self._download_jars(uri=uri, logger=logger)
else:
try:
module_dir = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
except Exception as e:
raise RuntimeEnvSetupError(
"Failed to download jar file: {}".format(e)
) from e
return get_directory_size_bytes(module_dir)
def modify_context(
self,
uris: List[str],
runtime_env_dict: Dict,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
for uri in uris:
module_dir = self._get_local_dir_from_uri(uri)
if not module_dir.exists():
raise ValueError(
f"Local directory {module_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading, unpacking or installing the java jar files."
)
context.java_jars.append(str(module_dir))
+149
View File
@@ -0,0 +1,149 @@
import asyncio
import copy
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from ray._common.utils import (
try_to_create_directory,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
# Nsight options used when runtime_env={"_nsight": "default"}
NSIGHT_DEFAULT_CONFIG = {
"t": "cuda,cudnn,cublas,nvtx",
"o": "'worker_process_%p'",
"stop-on-exit": "true",
}
def parse_nsight_config(nsight_config: Dict[str, str]) -> List[str]:
"""
Function to convert dictionary of nsight options into
nsight command line
The function returns:
- List[str]: nsys profile cmd line split into list of str
"""
nsight_cmd = ["nsys", "profile"]
for option, option_val in nsight_config.items():
# option standard based on
# https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
if len(option) > 1:
nsight_cmd.append(f"--{option}={option_val}")
else:
nsight_cmd += [f"-{option}", option_val]
return nsight_cmd
class NsightPlugin(RuntimeEnvPlugin):
name = "_nsight"
def __init__(self, resources_dir: str):
self.nsight_cmd = []
# replace this with better way to get logs dir
session_dir, runtime_dir = os.path.split(resources_dir)
self._nsight_dir = Path(session_dir) / "logs" / "nsight"
try_to_create_directory(self._nsight_dir)
async def _check_nsight_script(
self, nsight_config: Dict[str, str]
) -> Tuple[bool, str]:
"""
Function to validate if nsight_config is a valid nsight profile options
Args:
nsight_config: dictionary mapping nsight option to it's value
Returns:
a tuple consists of a boolean indicating if the nsight_config
is valid option and an error message if the nsight_config is invalid
"""
# use empty as nsight report test filename
nsight_config_copy = copy.deepcopy(nsight_config)
nsight_config_copy["o"] = str(Path(self._nsight_dir) / "empty")
nsight_cmd = parse_nsight_config(nsight_config_copy)
try:
nsight_cmd = nsight_cmd + [sys.executable, "-c", '""']
process = await asyncio.create_subprocess_exec(
*nsight_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
error_msg = stderr.strip() if stderr.strip() != "" else stdout.strip()
# cleanup test.nsys-rep file
clean_up_cmd = ["rm", f"{nsight_config_copy['o']}.nsys-rep"]
cleanup_process = await asyncio.create_subprocess_exec(
*clean_up_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_, _ = await cleanup_process.communicate()
if process.returncode == 0:
return True, None
else:
return False, error_msg
except FileNotFoundError:
return False, ("nsight is not installed")
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
nsight_config = runtime_env.nsight()
if not nsight_config:
return 0
if nsight_config and sys.platform != "linux":
raise RuntimeEnvSetupError(
"Nsight CLI is only available in Linux.\n"
"More information can be found in "
"https://docs.nvidia.com/nsight-compute/NsightComputeCli/index.html"
)
if isinstance(nsight_config, str):
if nsight_config == "default":
nsight_config = NSIGHT_DEFAULT_CONFIG
else:
raise RuntimeEnvSetupError(
f"Unsupported nsight config: {nsight_config}. "
"The supported config is 'default' or "
"Dictionary of nsight options"
)
is_valid_nsight_cmd, error_msg = await self._check_nsight_script(nsight_config)
if not is_valid_nsight_cmd:
logger.warning(error_msg)
raise RuntimeEnvSetupError(
"nsight profile failed to run with the following "
f"error message:\n {error_msg}"
)
# add set output path to logs dir
nsight_config["o"] = str(
Path(self._nsight_dir) / nsight_config.get("o", NSIGHT_DEFAULT_CONFIG["o"])
)
self.nsight_cmd = parse_nsight_config(nsight_config)
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running nsight profiler")
context.py_executable = " ".join(self.nsight_cmd) + " python"
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
import asyncio
import hashlib
import json
import logging
import os
import re
import shutil
import sys
from asyncio import create_task, get_running_loop
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env import dependency_utils, virtualenv_utils
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.utils import check_output_cmd
from ray._private.utils import get_directory_size_bytes
default_logger = logging.getLogger(__name__)
# Matches unresolved environment variable placeholders such as
# ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR} or $VAR. Such placeholders are
# expanded by the runtime env agent only after the driver-side hash is
# computed, so any path containing one is not a real path on the driver
# and must not be opened during hash computation.
_ENV_VAR_PATTERN = re.compile(r"\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*")
def _parse_requirements_file(file_path: str) -> List[str]:
packages = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
# Strip whitespace and remove inline comments (preceded by a space)
line = line.split(" #")[0].strip()
if not line or line.startswith("#"):
continue
packages.append(line)
return packages
def _get_pip_hash(pip_dict: Dict) -> str:
pip_dict_copy = pip_dict.copy()
# Using a list as a stack for iterative processing to handle nested requirements.
# Each item is a tuple (package_spec, parent_dir), where parent_dir is the directory
# of the file that contained this package spec (None for top-level packages)
packages_to_process = [
(pkg, None) for pkg in reversed(pip_dict_copy.get("packages", []))
]
expanded_packages = []
# Track visited files using absolute paths to prevent circular references
visited_files = set()
while packages_to_process:
pkg, parent_dir = packages_to_process.pop()
file_path = None
if pkg.startswith("-r"):
file_path = pkg[2:].lstrip()
elif pkg.startswith("--requirement"):
file_path = pkg[len("--requirement") :].lstrip()
if file_path.startswith("="):
file_path = file_path[1:].lstrip()
else:
expanded_packages.append(pkg)
continue
if file_path is not None:
# If the path contains an unresolved environment variable
# placeholder (e.g. ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR}),
# we cannot open it on the driver. Keep the original spec
# string in the hash input so the URI still reflects the user
# input, and let the runtime env agent expand and read the
# file on the worker side.
if _ENV_VAR_PATTERN.search(file_path):
expanded_packages.append(pkg)
continue
if parent_dir and not os.path.isabs(file_path):
file_path = os.path.join(parent_dir, file_path)
try:
abs_file_path = os.path.abspath(file_path)
except Exception:
default_logger.warning(f"Invalid path: {file_path}")
continue
if abs_file_path in visited_files:
default_logger.warning(
f"Skipping circular reference to {abs_file_path}"
)
continue
visited_files.add(abs_file_path)
file_dir = os.path.dirname(abs_file_path)
packages_from_file = _parse_requirements_file(abs_file_path)
packages_to_process.extend(
[(p, file_dir) for p in reversed(packages_from_file)]
)
pip_dict_copy["packages"] = expanded_packages
serialized_pip_spec = json.dumps(pip_dict_copy, sort_keys=True)
hash_val = hashlib.sha1(serialized_pip_spec.encode("utf-8")).hexdigest()
return hash_val
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"pip://<hashed_dependencies>"`, or None if no GC required."""
pip = runtime_env.get("pip")
if pip is not None:
if isinstance(pip, dict):
uri = "pip://" + _get_pip_hash(pip_dict=pip)
elif isinstance(pip, list):
uri = "pip://" + _get_pip_hash(pip_dict=dict(packages=pip))
else:
raise TypeError(
"pip field received by RuntimeEnvAgent must be "
f"list or dict, not {type(pip).__name__}."
)
else:
uri = None
return uri
class PipProcessor:
def __init__(
self,
target_dir: str,
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
):
try:
import virtualenv # noqa: F401 ensure virtualenv exists.
except ImportError:
raise RuntimeError(
f"Please install virtualenv "
f"`{sys.executable} -m pip install virtualenv`"
f"to enable pip runtime env."
)
logger.debug("Setting up pip for runtime_env: %s", runtime_env)
self._target_dir = target_dir
self._runtime_env = runtime_env
self._logger = logger
self._pip_config = self._runtime_env.pip_config()
self._pip_env = os.environ.copy()
self._pip_env.update(self._runtime_env.env_vars())
@classmethod
async def _ensure_pip_version(
cls,
path: str,
pip_version: Optional[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Run the pip command to reinstall pip to the specified version."""
if not pip_version:
return
python = virtualenv_utils.get_virtualenv_python(path)
# Ensure pip version.
pip_reinstall_cmd = [
python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
f"pip{pip_version}",
]
logger.info("Installing pip with version %s", pip_version)
await check_output_cmd(pip_reinstall_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _pip_check(
self,
path: str,
pip_check: bool,
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Run the pip check command to check python dependency conflicts.
If exists conflicts, the exit code of pip check command will be non-zero.
"""
if not pip_check:
logger.info("Skip pip check.")
return
python = virtualenv_utils.get_virtualenv_python(path)
await check_output_cmd(
[python, "-m", "pip", "check", "--disable-pip-version-check"],
logger=logger,
cwd=cwd,
env=pip_env,
)
logger.info("Pip check on %s successfully.", path)
async def _install_pip_packages(
self,
path: str,
pip_packages: List[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
# TODO(fyrestone): Support -i, --no-deps, --no-cache-dir, ...
pip_requirements_file = dependency_utils.get_requirements_file(
path, pip_packages
)
# Avoid blocking the event loop.
loop = get_running_loop()
await loop.run_in_executor(
None,
dependency_utils.gen_requirements_txt,
pip_requirements_file,
pip_packages,
)
# Install all dependencies
# The default options for pip install are
#
# --disable-pip-version-check
# Don't periodically check PyPI to determine whether a new version
# of pip is available for download.
#
# --no-cache-dir
# Disable the cache, the pip runtime env is a one-time installation,
# and we don't need to handle the pip cache broken.
#
# Allow users to specify their own options to install packages via `pip`.
pip_install_cmd = [
python,
"-m",
"pip",
"install",
"-r",
pip_requirements_file,
]
pip_opt_list = self._pip_config.get(
"pip_install_options", ["--disable-pip-version-check", "--no-cache-dir"]
)
pip_install_cmd.extend(pip_opt_list)
logger.info("Installing python requirements to %s", virtualenv_path)
await check_output_cmd(pip_install_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _run(self):
path = self._target_dir
logger = self._logger
pip_packages = self._pip_config["packages"]
# We create an empty directory for exec cmd so that the cmd will
# run more stable. e.g. if cwd has ray, then checking ray will
# look up ray in cwd instead of site packages.
exec_cwd = os.path.join(path, "exec_cwd")
os.makedirs(exec_cwd, exist_ok=True)
try:
await virtualenv_utils.create_or_get_virtualenv(path, exec_cwd, logger)
python = virtualenv_utils.get_virtualenv_python(path)
async with dependency_utils.check_ray(python, exec_cwd, logger):
# Ensure pip version.
await self._ensure_pip_version(
path,
self._pip_config.get("pip_version", None),
exec_cwd,
self._pip_env,
logger,
)
# Install pip packages.
await self._install_pip_packages(
path,
pip_packages,
exec_cwd,
self._pip_env,
logger,
)
# Check python environment for conflicts.
await self._pip_check(
path,
self._pip_config.get("pip_check", False),
exec_cwd,
self._pip_env,
logger,
)
except Exception:
logger.info("Delete incomplete virtualenv: %s", path)
shutil.rmtree(path, ignore_errors=True)
logger.exception("Failed to install pip packages.")
raise
def __await__(self):
return self._run().__await__()
class PipPlugin(RuntimeEnvPlugin):
name = "pip"
def __init__(self, resources_dir: str):
self._pip_resources_dir = os.path.join(resources_dir, "pip")
self._creating_task = {}
# Maps a URI to a lock that is used to prevent multiple concurrent
# installs of the same virtualenv, see #24513
self._create_locks: Dict[str, asyncio.Lock] = {}
# Key: created hashes. Value: size of the pip dir.
self._created_hash_bytes: Dict[str, int] = {}
try_to_create_directory(self._pip_resources_dir)
def _get_path_from_hash(self, hash_val: str) -> str:
"""Generate a path from the hash of a pip spec.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/pip/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._pip_resources_dir, hash_val)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the pip URI from the RuntimeEnv if it exists, else return []."""
pip_uri = runtime_env.pip_uri()
if pip_uri:
return [pip_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete pip URI %s", uri)
protocol, hash_val = parse_uri(uri)
if protocol != Protocol.PIP:
raise ValueError(
"PipPlugin can only delete URIs with protocol "
f"pip. Received protocol {protocol}, URI {uri}"
)
# Cancel running create task.
task = self._creating_task.pop(hash_val, None)
if task is not None:
task.cancel()
del self._created_hash_bytes[hash_val]
pip_env_path = self._get_path_from_hash(hash_val)
local_dir_size = get_directory_size_bytes(pip_env_path)
del self._create_locks[uri]
try:
shutil.rmtree(pip_env_path)
except OSError as e:
logger.warning(f"Error when deleting pip env {pip_env_path}: {str(e)}")
return 0
return local_dir_size
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not runtime_env.has_pip():
return 0
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
async def _create_for_hash():
await PipProcessor(
target_dir,
runtime_env,
logger,
)
loop = get_running_loop()
return await loop.run_in_executor(
None, get_directory_size_bytes, target_dir
)
if uri not in self._create_locks:
# async lock to prevent the same virtualenv being concurrently installed
self._create_locks[uri] = asyncio.Lock()
async with self._create_locks[uri]:
if hash_val in self._created_hash_bytes:
return self._created_hash_bytes[hash_val]
self._creating_task[hash_val] = task = create_task(_create_for_hash())
task.add_done_callback(lambda _: self._creating_task.pop(hash_val, None))
pip_dir_bytes = await task
self._created_hash_bytes[hash_val] = pip_dir_bytes
return pip_dir_bytes
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: logging.Logger = default_logger,
):
if not runtime_env.has_pip():
return
# PipPlugin only uses a single URI.
uri = uris[0]
# Update py_executable.
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
virtualenv_python = virtualenv_utils.get_virtualenv_python(target_dir)
if not os.path.exists(virtualenv_python):
raise ValueError(
f"Local directory {target_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"installing the runtime_env `pip` packages."
)
context.py_executable = virtualenv_python
context.command_prefix += virtualenv_utils.get_virtualenv_activate_command(
target_dir
)
+265
View File
@@ -0,0 +1,265 @@
import json
import logging
import os
from abc import ABC
from typing import Any, Dict, List, Optional, Type
from ray._common.utils import import_attr
from ray._private.runtime_env.constants import (
RAY_RUNTIME_ENV_CLASS_FIELD_NAME,
RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY,
RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY,
RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY,
RAY_RUNTIME_ENV_PLUGINS_ENV_VAR,
RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.uri_cache import URICache
from ray.util.annotations import DeveloperAPI
default_logger = logging.getLogger(__name__)
@DeveloperAPI
class RuntimeEnvPlugin(ABC):
"""Abstract base class for runtime environment plugins."""
name: str = None
priority: int = RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY
@staticmethod
def validate(runtime_env_dict: dict) -> None:
"""Validate user entry for this plugin.
The method is invoked upon installation of runtime env.
Args:
runtime_env_dict: The user-supplied runtime environment dict.
Raises:
ValueError: If the validation fails.
"""
pass
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
return []
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
"""Create and install the runtime environment.
Gets called in the runtime env agent at install time. The URI can be
used as a caching mechanism.
Args:
uri: A URI uniquely describing this resource.
runtime_env: The RuntimeEnv object.
context: Auxiliary information supplied by Ray.
logger: A logger to log messages during the context modification.
Returns:
float: The disk space taken up by this plugin installation for this
environment. e.g. for working_dir, this downloads the files to the
local node.
"""
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
"""Modify context to change worker startup behavior.
For example, you can use this to prepend "cd <dir>" command to worker
startup, or add new environment variables.
Args:
uris: The URIs used by this resource.
runtime_env: The RuntimeEnv object.
context: Auxiliary information supplied by Ray.
logger: A logger to log messages during the context modification.
"""
return
def delete_uri(self, uri: str, logger: logging.Logger) -> float:
"""Delete the runtime environment given uri.
Args:
uri: A URI uniquely describing this resource.
logger: The logger used to log messages during the deletion.
Returns:
float: The amount of space reclaimed by the deletion.
"""
return 0
class PluginSetupContext:
def __init__(
self,
name: str,
class_instance: RuntimeEnvPlugin,
priority: int,
uri_cache: URICache,
):
self.name = name
self.class_instance = class_instance
self.priority = priority
self.uri_cache = uri_cache
class RuntimeEnvPluginManager:
"""This manager is used to load plugins in runtime env agent."""
def __init__(self):
self.plugins: Dict[str, PluginSetupContext] = {}
plugin_config_str = os.environ.get(RAY_RUNTIME_ENV_PLUGINS_ENV_VAR)
if plugin_config_str:
plugin_configs = json.loads(plugin_config_str)
self.load_plugins(plugin_configs)
def validate_plugin_class(self, plugin_class: Type[RuntimeEnvPlugin]) -> None:
if not issubclass(plugin_class, RuntimeEnvPlugin):
raise RuntimeError(
f"Invalid runtime env plugin class {plugin_class}. "
"The plugin class must inherit "
"ray._private.runtime_env.plugin.RuntimeEnvPlugin."
)
if not plugin_class.name:
raise RuntimeError(f"No valid name in runtime env plugin {plugin_class}.")
if plugin_class.name in self.plugins:
raise RuntimeError(
f"The name of runtime env plugin {plugin_class} conflicts "
f"with {self.plugins[plugin_class.name]}.",
)
def validate_priority(self, priority: Any) -> None:
if (
not isinstance(priority, int)
or priority < RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY
or priority > RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY
):
raise RuntimeError(
f"Invalid runtime env priority {priority}, "
"it should be an integer between "
f"{RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY} "
f"and {RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY}."
)
def load_plugins(self, plugin_configs: List[Dict]) -> None:
"""Load runtime env plugins and create URI caches for them."""
for plugin_config in plugin_configs:
if (
not isinstance(plugin_config, dict)
or RAY_RUNTIME_ENV_CLASS_FIELD_NAME not in plugin_config
):
raise RuntimeError(
f"Invalid runtime env plugin config {plugin_config}, "
"it should be a object which contains the "
f"{RAY_RUNTIME_ENV_CLASS_FIELD_NAME} field."
)
plugin_class = import_attr(plugin_config[RAY_RUNTIME_ENV_CLASS_FIELD_NAME])
self.validate_plugin_class(plugin_class)
# The priority should be an integer between 0 and 100.
# The default priority is 10. A smaller number indicates a
# higher priority and the plugin will be set up first.
if RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME in plugin_config:
priority = plugin_config[RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME]
else:
priority = plugin_class.priority
self.validate_priority(priority)
class_instance = plugin_class()
self.plugins[plugin_class.name] = PluginSetupContext(
plugin_class.name,
class_instance,
priority,
self.create_uri_cache_for_plugin(class_instance),
)
def add_plugin(self, plugin: RuntimeEnvPlugin) -> None:
"""Add a plugin to the manager and create a URI cache for it.
Args:
plugin: The class instance of the plugin.
"""
plugin_class = type(plugin)
self.validate_plugin_class(plugin_class)
self.validate_priority(plugin_class.priority)
self.plugins[plugin_class.name] = PluginSetupContext(
plugin_class.name,
plugin,
plugin_class.priority,
self.create_uri_cache_for_plugin(plugin),
)
def create_uri_cache_for_plugin(self, plugin: RuntimeEnvPlugin) -> URICache:
"""Create a URI cache for a plugin.
Args:
plugin: The plugin instance whose URIs the cache will manage.
Returns:
The created URI cache for the plugin.
"""
# Set the max size for the cache. Defaults to 10 GB.
cache_size_env_var = f"RAY_RUNTIME_ENV_{plugin.name}_CACHE_SIZE_GB".upper()
cache_size_bytes = int(
(1024**3) * float(os.environ.get(cache_size_env_var, 10))
)
return URICache(plugin.delete_uri, cache_size_bytes)
def sorted_plugin_setup_contexts(self) -> List[PluginSetupContext]:
"""Get the sorted plugin setup contexts, sorted by increasing priority.
Returns:
The sorted plugin setup contexts.
"""
return sorted(self.plugins.values(), key=lambda x: x.priority)
async def create_for_plugin_if_needed(
runtime_env: "RuntimeEnv", # noqa: F821
plugin: RuntimeEnvPlugin,
uri_cache: URICache,
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
):
"""Set up the environment using the plugin if not already set up and cached."""
if plugin.name not in runtime_env or runtime_env[plugin.name] is None:
return
plugin.validate(runtime_env)
uris = plugin.get_uris(runtime_env)
if not uris:
logger.debug(
f"No URIs for runtime env plugin {plugin.name}; "
"create always without checking the cache."
)
await plugin.create(None, runtime_env, context, logger=logger)
for uri in uris:
if uri not in uri_cache:
logger.debug(f"Cache miss for URI {uri}.")
size_bytes = await plugin.create(uri, runtime_env, context, logger=logger)
uri_cache.add(uri, size_bytes, logger=logger)
else:
logger.info(
f"Runtime env {plugin.name} {uri} is already installed "
"and will be reused. Search "
"all runtime_env_setup-*.log to find the corresponding setup log."
)
uri_cache.mark_used(uri, logger=logger)
plugin.modify_context(uris, runtime_env, context, logger)
@@ -0,0 +1,97 @@
import json
import logging
import os
from typing import List
import jsonschema
from ray._private.runtime_env.constants import (
RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX,
RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR,
)
logger = logging.getLogger(__name__)
class RuntimeEnvPluginSchemaManager:
"""This manager is used to load plugin json schemas."""
default_schema_path = os.path.join(
os.path.dirname(__file__), "../../runtime_env/schemas"
)
schemas = {}
loaded = False
@classmethod
def _load_schemas(cls, schema_paths: List[str]):
for schema_path in schema_paths:
try:
with open(schema_path) as f:
schema = json.load(f)
except json.decoder.JSONDecodeError:
logger.error("Invalid runtime env schema %s, skip it.", schema_path)
continue
except OSError:
logger.error("Cannot open runtime env schema %s, skip it.", schema_path)
continue
if "title" not in schema:
logger.error(
"No valid title in runtime env schema %s, skip it.", schema_path
)
continue
if schema["title"] in cls.schemas:
logger.error(
"The 'title' of runtime env schema %s conflicts with %s, skip it.",
schema_path,
cls.schemas[schema["title"]],
)
continue
cls.schemas[schema["title"]] = schema
@classmethod
def _load_default_schemas(cls):
schema_json_files = list()
for root, _, files in os.walk(cls.default_schema_path):
for f in files:
if f.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(os.path.join(root, f))
logger.debug(
f"Loading the default runtime env schemas: {schema_json_files}."
)
cls._load_schemas(schema_json_files)
@classmethod
def _load_schemas_from_env_var(cls):
# The format of env var:
# "/path/to/env_1_schema.json,/path/to/env_2_schema.json,/path/to/schemas_dir/"
schema_paths = os.environ.get(RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR)
if schema_paths:
schema_json_files = list()
for path in schema_paths.split(","):
if path.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(path)
elif os.path.isdir(path):
for root, _, files in os.walk(path):
for f in files:
if f.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(os.path.join(root, f))
logger.info(
f"Loading the runtime env schemas from env var: {schema_json_files}."
)
cls._load_schemas(schema_json_files)
@classmethod
def validate(cls, name, instance):
if not cls.loaded:
# Load the schemas lazily.
cls._load_default_schemas()
cls._load_schemas_from_env_var()
cls.loaded = True
# if no schema matches, skip the validation.
if name in cls.schemas:
jsonschema.validate(instance=instance, schema=cls.schemas[name])
@classmethod
def clear(cls):
cls.schemas.clear()
cls.loaded = False
+315
View File
@@ -0,0 +1,315 @@
import enum
import os
from urllib.parse import urlparse
RAY_RUNTIME_ENV_HTTP_USER_AGENT_ENV_VAR = "RAY_RUNTIME_ENV_HTTP_USER_AGENT"
RAY_RUNTIME_ENV_BEARER_TOKEN_ENV_VAR = "RAY_RUNTIME_ENV_BEARER_TOKEN"
_DEFAULT_HTTP_USER_AGENT = "ray-runtime-env-curl/1.0"
class ProtocolsProvider:
_MISSING_DEPENDENCIES_WARNING = (
"Note that these must be preinstalled "
"on all nodes in the Ray cluster; it is not "
"sufficient to install them in the runtime_env."
)
@classmethod
def get_protocols(cls):
return {
# For packages dynamically uploaded and managed by the GCS.
"gcs",
# For conda environments installed locally on each node.
"conda",
# For pip environments installed locally on each node.
"pip",
# For uv environments install locally on each node.
"uv",
# Remote http path, assumes everything packed in one zip file.
"http",
# Remote https path, assumes everything packed in one zip file.
"https",
# Remote s3 path, assumes everything packed in one zip file.
"s3",
# Remote google storage path, assumes everything packed in one zip file.
"gs",
# Remote azure blob storage path, assumes everything packed in one zip file.
"azure",
# Remote Azure Blob File System Secure path, assumes everything packed in one zip file.
"abfss",
# File storage path, assumes everything packed in one zip file.
"file",
}
@classmethod
def get_remote_protocols(cls):
return {"http", "https", "s3", "gs", "azure", "abfss", "file"}
@classmethod
def _handle_s3_protocol(cls):
"""Set up S3 protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
"""
try:
import boto3
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open[s3]` "
"to fetch URIs in s3 bucket. " + cls._MISSING_DEPENDENCIES_WARNING
)
# Create S3 client, falling back to unsigned for public buckets
session = boto3.Session()
# session.get_credentials() will return None if no credentials can be found.
if session.get_credentials():
# If credentials are found, use a standard signed client.
s3_client = session.client("s3")
else:
# No credentials found, fall back to an unsigned client for public buckets.
from botocore import UNSIGNED
from botocore.config import Config
s3_client = boto3.client("s3", config=Config(signature_version=UNSIGNED))
transport_params = {"client": s3_client}
return open_file, transport_params
@classmethod
def _handle_gs_protocol(cls):
"""Set up Google Cloud Storage protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
"""
try:
from google.cloud import storage # noqa: F401
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open[gcs]` "
"to fetch URIs in Google Cloud Storage bucket."
+ cls._MISSING_DEPENDENCIES_WARNING
)
return open_file, None
@classmethod
def _handle_azure_protocol(cls):
"""Set up Azure blob storage protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
ValueError: If required environment variables are not set.
"""
try:
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient # noqa: F401
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install azure-storage-blob azure-identity smart_open[azure]` "
"to fetch URIs in Azure Blob Storage. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
# Define authentication variable
azure_storage_account_name = os.getenv("AZURE_STORAGE_ACCOUNT")
if not azure_storage_account_name:
raise ValueError(
"Azure Blob Storage authentication requires "
"AZURE_STORAGE_ACCOUNT environment variable to be set."
)
account_url = f"https://{azure_storage_account_name}.blob.core.windows.net/"
transport_params = {
"client": BlobServiceClient(
account_url=account_url, credential=DefaultAzureCredential()
)
}
return open_file, transport_params
@classmethod
def _handle_abfss_protocol(cls):
"""Set up Azure Blob File System Secure (ABFSS) protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
ValueError: If the ABFSS URI format is invalid.
"""
try:
import adlfs
from azure.identity import DefaultAzureCredential
except ImportError:
raise ImportError(
"You must `pip install adlfs azure-identity` "
"to fetch URIs in Azure Blob File System Secure. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
def open_file(uri, mode, *, transport_params=None):
# Parse and validate the ABFSS URI
parsed = urlparse(uri)
# Validate ABFSS URI format: abfss://container@account.dfs.core.windows.net/path
if not parsed.netloc or "@" not in parsed.netloc:
raise ValueError(
f"Invalid ABFSS URI format - missing container@account: {uri}"
)
container_part, hostname_part = parsed.netloc.split("@", 1)
# Validate container name (must be non-empty)
if not container_part:
raise ValueError(
f"Invalid ABFSS URI format - empty container name: {uri}"
)
# Validate hostname format
if not hostname_part or not hostname_part.endswith(".dfs.core.windows.net"):
raise ValueError(
f"Invalid ABFSS URI format - invalid hostname (must end with .dfs.core.windows.net): {uri}"
)
# Extract and validate account name
azure_storage_account_name = hostname_part.split(".")[0]
if not azure_storage_account_name:
raise ValueError(
f"Invalid ABFSS URI format - empty account name: {uri}"
)
# Handle ABFSS URI with adlfs
filesystem = adlfs.AzureBlobFileSystem(
account_name=azure_storage_account_name,
credential=DefaultAzureCredential(),
)
return filesystem.open(uri, mode)
return open_file, None
@classmethod
def _http_headers(cls) -> dict:
headers = {
"User-Agent": os.environ.get(
RAY_RUNTIME_ENV_HTTP_USER_AGENT_ENV_VAR, _DEFAULT_HTTP_USER_AGENT
),
"Accept": "*/*",
}
bearer_token = os.environ.get(RAY_RUNTIME_ENV_BEARER_TOKEN_ENV_VAR)
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
return headers
@classmethod
def _handle_http_protocol(cls):
"""Set up HTTP/HTTPS protocol handling with curl-like headers."""
try:
from smart_open import open as smart_open_open
except ImportError:
raise ImportError(
"You must `pip install smart_open` to fetch HTTP/HTTPS URIs. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
def open_file(uri, mode, *, transport_params=None):
params = {
"headers": cls._http_headers(),
"timeout": 60,
}
if transport_params:
params.update(transport_params)
return smart_open_open(uri, mode, transport_params=params)
return open_file, None
@classmethod
def download_remote_uri(cls, protocol: str, source_uri: str, dest_file: str):
"""Download file from remote URI to destination file.
Args:
protocol: The protocol to use for downloading (e.g., 's3', 'https').
source_uri: The source URI to download from.
dest_file: The destination file path to save to.
Raises:
ImportError: If required dependencies for the protocol are not installed.
"""
assert protocol in cls.get_remote_protocols()
tp = None
open_file = None
if protocol == "file":
source_uri = source_uri[len("file://") :]
def open_file(uri, mode, *, transport_params=None):
return open(uri, mode)
elif protocol in ("http", "https"):
open_file, tp = cls._handle_http_protocol()
elif protocol == "s3":
open_file, tp = cls._handle_s3_protocol()
elif protocol == "gs":
open_file, tp = cls._handle_gs_protocol()
elif protocol == "azure":
open_file, tp = cls._handle_azure_protocol()
elif protocol == "abfss":
open_file, tp = cls._handle_abfss_protocol()
else:
try:
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open` "
f"to fetch {protocol.upper()} URIs. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
with open_file(source_uri, "rb", transport_params=tp) as fin:
with open(dest_file, "wb") as fout:
fout.write(fin.read())
Protocol = enum.Enum(
"Protocol",
{protocol.upper(): protocol for protocol in ProtocolsProvider.get_protocols()},
)
@classmethod
def _remote_protocols(cls):
# Returns a list of protocols that support remote storage
# These protocols should only be used with paths that end in
# ".zip", ".whl", ".tar.gz", or ".tgz"
return [
cls[protocol.upper()] for protocol in ProtocolsProvider.get_remote_protocols()
]
Protocol.remote_protocols = _remote_protocols
def _download_remote_uri(self, source_uri, dest_file):
return ProtocolsProvider.download_remote_uri(self.value, source_uri, dest_file)
Protocol.download_remote_uri = _download_remote_uri
@@ -0,0 +1,45 @@
import logging
from typing import List, Optional
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
default_logger = logging.getLogger(__name__)
class PyExecutablePlugin(RuntimeEnvPlugin):
"""This plugin allows running Ray workers with a custom Python executable.
You can use it with
`ray.init(runtime_env={"py_executable": "<command> <args>"})`. If you specify
a `working_dir` in the runtime environment, the executable will have access
to the working directory, for example, to a requirements.txt for a package manager,
a script for a debugger, or the executable could be a shell script in the
working directory. You can also use this plugin to run worker processes
in a custom profiler or use a custom Python interpreter or `python` with
custom arguments.
"""
name = "py_executable"
def __init__(self):
pass
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running py_executable plugin")
context.py_executable = runtime_env.py_executable()
@@ -0,0 +1,242 @@
import logging
import os
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import (
Protocol,
delete_package,
download_and_unpack_package,
get_local_dir_from_uri,
get_uri_for_directory,
get_uri_for_file,
get_uri_for_package,
install_wheel_package,
is_whl_uri,
package_exists,
parse_uri,
upload_package_if_needed,
upload_package_to_gcs,
)
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.working_dir import set_pythonpath_in_context
from ray._private.utils import get_directory_size_bytes
from ray._raylet import GcsClient
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
def _check_is_uri(s: str) -> bool:
try:
protocol, path = parse_uri(s)
except ValueError:
protocol, path = None, None
supported_extensions = (".zip", ".whl", ".tar.gz", ".tgz")
if protocol in Protocol.remote_protocols() and not any(
path.endswith(ext) for ext in supported_extensions
):
raise ValueError(
"Only .zip, .whl, .tar.gz, and .tgz files supported for remote URIs."
)
return protocol is not None
def upload_py_modules_if_needed(
runtime_env: Dict[str, Any],
include_gitignore: bool,
scratch_dir: Optional[str] = None,
logger: Optional[logging.Logger] = default_logger,
upload_fn=None,
) -> Dict[str, Any]:
"""Uploads the entries in py_modules and replaces them with a list of URIs.
For each entry that is already a URI, this is a no-op.
"""
py_modules = runtime_env.get("py_modules")
if py_modules is None:
return runtime_env
if not isinstance(py_modules, list):
raise TypeError(
"py_modules must be a List of local paths, imported modules, or "
f"URIs, got {type(py_modules)}."
)
py_modules_uris = []
for module in py_modules:
if isinstance(module, str):
# module_path is a local path or a URI.
module_path = module
elif isinstance(module, Path):
module_path = str(module)
elif isinstance(module, ModuleType):
if not hasattr(module, "__path__"):
# This is a single-file module.
module_path = module.__file__
else:
# NOTE(edoakes): Python allows some installed Python packages to
# be split into multiple directories. We could probably handle
# this, but it seems tricky & uncommon. If it's a problem for
# users, we can add this support on demand.
if len(module.__path__) > 1:
raise ValueError(
"py_modules only supports modules whose __path__"
" has length 1 or those who are single-file."
)
[module_path] = module.__path__
else:
raise TypeError(
"py_modules must be a list of file paths, URIs, "
f"or imported modules, got {type(module)}."
)
if _check_is_uri(module_path):
module_uri = module_path
else:
# module_path is a local path.
if Path(module_path).is_dir() or Path(module_path).suffix == ".py":
is_dir = Path(module_path).is_dir()
excludes = runtime_env.get("excludes", None)
if is_dir:
module_uri = get_uri_for_directory(
module_path,
include_gitignore=include_gitignore,
excludes=excludes,
)
else:
module_uri = get_uri_for_file(module_path)
if upload_fn is None:
if scratch_dir is None:
scratch_dir = os.getcwd()
try:
upload_package_if_needed(
module_uri,
scratch_dir,
module_path,
include_gitignore=include_gitignore,
include_parent_dir=is_dir,
excludes=excludes,
logger=logger,
)
except Exception as e:
from ray.util.spark.utils import is_in_databricks_runtime
if is_in_databricks_runtime():
raise RuntimeEnvSetupError(
f"Failed to upload module {module_path} to the Ray "
f"cluster, please ensure there are only files under "
f"the module path, notebooks under the path are "
f"not allowed, original exception: {e}"
) from e
raise RuntimeEnvSetupError(
f"Failed to upload module {module_path} to the Ray "
f"cluster: {e}"
) from e
else:
upload_fn(module_path, excludes=excludes)
elif Path(module_path).suffix == ".whl":
module_uri = get_uri_for_package(Path(module_path))
if upload_fn is None:
if not package_exists(module_uri):
try:
upload_package_to_gcs(
module_uri, Path(module_path).read_bytes()
)
except Exception as e:
raise RuntimeEnvSetupError(
f"Failed to upload {module_path} to the Ray "
f"cluster: {e}"
) from e
else:
upload_fn(module_path, excludes=None, is_file=True)
else:
raise ValueError(
"py_modules entry must be a .py file, "
"a directory, or a .whl file; "
f"got {module_path}"
)
py_modules_uris.append(module_uri)
# TODO(architkulkarni): Expose a single URI for py_modules. This plugin
# should internally handle the "sub-URIs", the individual modules.
runtime_env["py_modules"] = py_modules_uris
return runtime_env
class PyModulesPlugin(RuntimeEnvPlugin):
name = "py_modules"
def __init__(self, resources_dir: str, gcs_client: GcsClient):
self._resources_dir = os.path.join(resources_dir, "py_modules_files")
self._gcs_client = gcs_client
try_to_create_directory(self._resources_dir)
def _get_local_dir_from_uri(self, uri: str):
return get_local_dir_from_uri(uri, self._resources_dir)
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete pymodule URI %s", uri)
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
local_dir_size = get_directory_size_bytes(local_dir)
deleted = delete_package(uri, self._resources_dir)
if not deleted:
logger.warning(f"Tried to delete nonexistent URI: {uri}.")
return 0
return local_dir_size
def get_uris(self, runtime_env) -> List[str]:
return runtime_env.py_modules()
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
) -> int:
module_dir = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
if is_whl_uri(uri):
wheel_uri = module_dir
module_dir = self._get_local_dir_from_uri(uri)
await install_wheel_package(
wheel_uri=wheel_uri, target_dir=module_dir, logger=logger
)
return get_directory_size_bytes(module_dir)
def modify_context(
self,
uris: List[str],
runtime_env_dict: Dict,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
module_dirs = []
for uri in uris:
module_dir = self._get_local_dir_from_uri(uri)
if not module_dir.exists():
raise ValueError(
f"Local directory {module_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading, unpacking or installing the py_modules files."
)
module_dirs.append(str(module_dir))
set_pythonpath_in_context(os.pathsep.join(module_dirs), context)
@@ -0,0 +1,173 @@
import asyncio
import copy
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
# rocprof-sys config used when runtime_env={"_rocprof_sys": "default"}
# Refer to the following link for more information on rocprof-sys options
# https://rocm.docs.amd.com/projects/rocprofiler-systems/en/docs-6.4.0/how-to/understanding-rocprof-sys-output.html
ROCPROFSYS_DEFAULT_CONFIG = {
"env": {
"ROCPROFSYS_TIME_OUTPUT": "false",
"ROCPROFSYS_OUTPUT_PREFIX": "worker_process_%p",
},
"args": {
"F": "true",
},
}
def parse_rocprof_sys_config(
rocprof_sys_config: Dict[str, str]
) -> Tuple[List[str], List[str]]:
"""
Function to convert dictionary of rocprof-sys options into
rocprof-sys-python command line
The function returns:
- List[str]: rocprof-sys-python cmd line split into list of str
"""
rocprof_sys_cmd = ["rocprof-sys-python"]
rocprof_sys_env = {}
if "args" in rocprof_sys_config:
# Parse rocprof-sys arg options
for option, option_val in rocprof_sys_config["args"].items():
# option standard based on
# https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
if len(option) > 1:
rocprof_sys_cmd.append(f"--{option}={option_val}")
else:
rocprof_sys_cmd += [f"-{option}", option_val]
if "env" in rocprof_sys_config:
rocprof_sys_env = rocprof_sys_config["env"]
rocprof_sys_cmd.append("--")
return rocprof_sys_cmd, rocprof_sys_env
class RocProfSysPlugin(RuntimeEnvPlugin):
name = "_rocprof_sys"
def __init__(self, resources_dir: str):
self.rocprof_sys_cmd = []
self.rocprof_sys_env = {}
# replace this with better way to get logs dir
session_dir, runtime_dir = os.path.split(resources_dir)
self._rocprof_sys_dir = Path(session_dir) / "logs" / "rocprof_sys"
try_to_create_directory(self._rocprof_sys_dir)
async def _check_rocprof_sys_script(
self, rocprof_sys_config: Dict[str, str]
) -> Tuple[bool, str]:
"""
Function to validate if rocprof_sys_config is a valid rocprof_sys profile options
Args:
rocprof_sys_config: dictionary mapping rocprof_sys option to it's value
Returns:
a tuple consists of a boolean indicating if the rocprof_sys_config
is valid option and an error message if the rocprof_sys_config is invalid
"""
# use empty as rocprof_sys report test filename
test_folder = str(Path(self._rocprof_sys_dir) / "test")
rocprof_sys_cmd, rocprof_sys_env = parse_rocprof_sys_config(rocprof_sys_config)
rocprof_sys_env_copy = copy.deepcopy(rocprof_sys_env)
rocprof_sys_env_copy["ROCPROFSYS_OUTPUT_PATH"] = test_folder
rocprof_sys_env_copy.update(os.environ)
try_to_create_directory(test_folder)
# Create a test python file to run rocprof_sys
with open(f"{test_folder}/test.py", "w") as f:
f.write("import time\n")
try:
rocprof_sys_cmd = rocprof_sys_cmd + [f"{test_folder}/test.py"]
process = await asyncio.create_subprocess_exec(
*rocprof_sys_cmd,
env=rocprof_sys_env_copy,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
error_msg = stderr.strip() if stderr.strip() != "" else stdout.strip()
# cleanup temp file
clean_up_cmd = ["rm", "-r", test_folder]
cleanup_process = await asyncio.create_subprocess_exec(
*clean_up_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_, _ = await cleanup_process.communicate()
if process.returncode == 0:
return True, None
else:
return False, error_msg
except FileNotFoundError:
return False, ("rocprof_sys is not installed")
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
rocprof_sys_config = runtime_env.rocprof_sys()
if not rocprof_sys_config:
return 0
if rocprof_sys_config and sys.platform != "linux":
raise RuntimeEnvSetupError("rocprof-sys CLI is only available in Linux.\n")
if isinstance(rocprof_sys_config, str):
if rocprof_sys_config == "default":
rocprof_sys_config = ROCPROFSYS_DEFAULT_CONFIG
else:
raise RuntimeEnvSetupError(
f"Unsupported rocprof_sys config: {rocprof_sys_config}. "
"The supported config is 'default' or "
"Dictionary of rocprof_sys options"
)
is_valid_rocprof_sys_config, error_msg = await self._check_rocprof_sys_script(
rocprof_sys_config
)
if not is_valid_rocprof_sys_config:
logger.warning(error_msg)
raise RuntimeEnvSetupError(
"rocprof-sys profile failed to run with the following "
f"error message:\n {error_msg}"
)
# add set output path to logs dir
if "env" not in rocprof_sys_config:
rocprof_sys_config["env"] = {}
rocprof_sys_config["env"]["ROCPROFSYS_OUTPUT_PATH"] = str(
Path(self._rocprof_sys_dir)
)
self.rocprof_sys_cmd, self.rocprof_sys_env = parse_rocprof_sys_config(
rocprof_sys_config
)
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running rocprof-sys profiler")
context.py_executable = " ".join(self.rocprof_sys_cmd)
context.env_vars.update(self.rocprof_sys_env)
@@ -0,0 +1,261 @@
import base64
import logging
import os
import traceback
from typing import Any, Callable, Dict, Optional, Union
import ray
import ray._private.ray_constants as ray_constants
import ray.cloudpickle as pickle
from ray._common.utils import load_class
from ray._private.function_manager import build_setup_hook_export_entry
from ray.runtime_env import RuntimeEnv
logger = logging.getLogger(__name__)
RUNTIME_ENV_FUNC_IDENTIFIER = "ray_runtime_env_func::"
def get_import_export_timeout():
return int(
os.environ.get(
ray_constants.RAY_WORKER_PROCESS_SETUP_HOOK_LOAD_TIMEOUT_ENV_VAR, "60"
)
)
def decode_function_key(key: bytes) -> str:
# b64encode only includes A-Z, a-z, 0-9, + and / characters
return RUNTIME_ENV_FUNC_IDENTIFIER + base64.b64encode(key).decode()
def _encode_function_key(key: str) -> bytes:
assert key.startswith(RUNTIME_ENV_FUNC_IDENTIFIER)
return base64.b64decode(key[len(RUNTIME_ENV_FUNC_IDENTIFIER) :])
def _raise_setup_hook_conflict(existing_hook_value: str, setup_hook_desc: str) -> None:
raise RuntimeError(
"Conflicting worker_process_setup_hook: the setup hook env "
f"var is already set to '{existing_hook_value}', but "
f"runtime_env specifies {setup_hook_desc}."
)
def export_setup_func_callable(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
setup_func: Callable,
worker: "ray.Worker",
) -> Union[Dict[str, Any], RuntimeEnv]:
assert isinstance(setup_func, Callable)
try:
key = worker.function_actor_manager.export_setup_func(
setup_func, timeout=get_import_export_timeout()
)
except Exception as e:
raise ray.exceptions.RuntimeEnvSetupError(
"Failed to export the setup function."
) from e
env_vars = runtime_env.get("env_vars", {})
assert ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR not in env_vars, (
f"The env var, {ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR}, "
"is not permitted because it is reserved for the internal use."
)
env_vars[ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR] = decode_function_key(key)
runtime_env["env_vars"] = env_vars
# Note: This field is no-op. We don't have a plugin for the setup hook
# because we can implement it simply using an env var.
# This field is just for the observability purpose, so we store
# the name of the method.
runtime_env["worker_process_setup_hook"] = setup_func.__name__
return runtime_env
def export_setup_func_module(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
setup_func_module: str,
) -> Union[Dict[str, Any], RuntimeEnv]:
assert isinstance(setup_func_module, str)
env_vars = runtime_env.get("env_vars", {})
assert ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR not in env_vars, (
f"The env var, {ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR}, "
"is not permitted because it is reserved for the internal use."
)
env_vars[ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR] = setup_func_module
runtime_env["env_vars"] = env_vars
return runtime_env
def _check_setup_hook_consistency(
existing_hook_value: str,
setup_func: Union[Callable, str],
worker: "ray.Worker",
) -> None:
"""Validate that an already-set hook env var is consistent with setup_func.
When the env var is already populated (e.g. inherited from a job supervisor),
we compare it against the `worker_process_setup_hook` field in the runtime_env
to detect silent mismatches.
Args:
existing_hook_value: The value of the existing hook env var.
setup_func: The setup function or module path.
worker: The worker instance.
Raises:
RuntimeError: If a conflict between the existing env var and setup_func is detected.
"""
if isinstance(setup_func, Callable):
try:
_encode_function_key(existing_hook_value)
except Exception:
_raise_setup_hook_conflict(
existing_hook_value, f"callable '{setup_func.__name__}'"
)
_check_callable_hooks_match(existing_hook_value, setup_func, worker)
elif isinstance(setup_func, str):
try:
_encode_function_key(existing_hook_value)
existing_is_callable_ref = True
except Exception:
existing_is_callable_ref = False
if existing_is_callable_ref or existing_hook_value != setup_func:
_raise_setup_hook_conflict(existing_hook_value, f"'{setup_func}'")
def _check_callable_hooks_match(
existing_hook_value: str,
setup_func: Callable,
worker: "ray.Worker",
) -> None:
"""Verify a callable produces the same GCS key as the existing env var."""
_, _, expected_key = build_setup_hook_export_entry(
setup_func, worker.current_job_id.binary()
)
expected_env_value = decode_function_key(expected_key)
if existing_hook_value != expected_env_value:
_raise_setup_hook_conflict(
existing_hook_value, f"callable '{setup_func.__name__}'"
)
def upload_worker_process_setup_hook_if_needed(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
worker: "ray.Worker",
) -> Union[Dict[str, Any], RuntimeEnv]:
"""Uploads the worker_process_setup_hook to GCS with a key.
runtime_env["worker_process_setup_hook"] is converted to a decoded key
that can load the worker setup hook function from GCS.
i.e., you can use internalKV.Get(runtime_env["worker_process_setup_hook])
to access the worker setup hook from GCS.
Args:
runtime_env: The runtime_env. The value will be modified
when returned.
worker: ray.worker instance.
Returns:
The modified runtime_env with the setup hook processed into an env var.
"""
setup_func = runtime_env.get("worker_process_setup_hook")
if setup_func is None:
return runtime_env
env_vars = runtime_env.get("env_vars", {})
existing_hook = env_vars.get(ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR)
if existing_hook is not None:
# A setup hook is already populated (e.g. inherited from job supervisor).
# Validate that it is consistent with the current worker_process_setup_hook.
_check_setup_hook_consistency(existing_hook, setup_func, worker)
return runtime_env
if isinstance(setup_func, Callable):
return export_setup_func_callable(runtime_env, setup_func, worker)
elif isinstance(setup_func, str):
return export_setup_func_module(runtime_env, setup_func)
else:
raise TypeError(
"worker_process_setup_hook must be a function, " f"got {type(setup_func)}."
)
def load_and_execute_setup_hook(
worker_process_setup_hook_key: str,
) -> Optional[str]:
"""Load the setup hook from a given key and execute.
Args:
worker_process_setup_hook_key: The key to import the setup hook
from GCS.
Returns:
An error message if it fails. None if it succeeds.
"""
assert worker_process_setup_hook_key is not None
if not worker_process_setup_hook_key.startswith(RUNTIME_ENV_FUNC_IDENTIFIER):
return load_and_execute_setup_hook_module(worker_process_setup_hook_key)
else:
return load_and_execute_setup_hook_func(worker_process_setup_hook_key)
def load_and_execute_setup_hook_module(
worker_process_setup_hook_key: str,
) -> Optional[str]:
try:
setup_func = load_class(worker_process_setup_hook_key)
setup_func()
return None
except Exception:
error_message = (
"Failed to execute the setup hook method, "
f"{worker_process_setup_hook_key} "
"from ``ray.init(runtime_env="
f"{{'worker_process_setup_hook': {worker_process_setup_hook_key}}})``. "
"Please make sure the given module exists and is available "
"from ray workers. For more details, see the error trace below.\n"
f"{traceback.format_exc()}"
)
return error_message
def load_and_execute_setup_hook_func(
worker_process_setup_hook_key: str,
) -> Optional[str]:
worker = ray._private.worker.global_worker
assert worker.connected
func_manager = worker.function_actor_manager
try:
worker_setup_func_info = func_manager.fetch_registered_method(
_encode_function_key(worker_process_setup_hook_key),
timeout=get_import_export_timeout(),
)
except Exception:
error_message = (
"Failed to import setup hook within "
f"{get_import_export_timeout()} seconds.\n"
f"{traceback.format_exc()}"
)
return error_message
try:
setup_func = pickle.loads(worker_setup_func_info.function)
except Exception:
error_message = (
"Failed to deserialize the setup hook method.\n" f"{traceback.format_exc()}"
)
return error_message
try:
setup_func()
except Exception:
error_message = (
f"Failed to execute the setup hook method. Function name:"
f"{worker_setup_func_info.function_name}\n"
f"{traceback.format_exc()}"
)
return error_message
return None
@@ -0,0 +1,115 @@
import logging
from typing import Callable, Optional, Set
default_logger = logging.getLogger(__name__)
DEFAULT_MAX_URI_CACHE_SIZE_BYTES = (1024**3) * 10 # 10 GB
class URICache:
"""Caches URIs up to a specified total size limit.
URIs are represented by strings. Each URI has an associated size on disk.
When a URI is added to the URICache, it is marked as "in use".
When a URI is no longer in use, the user of this class should call
`mark_unused` to signal that the URI is safe for deletion.
URIs in the cache can be marked as "in use" by calling `mark_used`.
Deletion of URIs on disk does not occur until the size limit is exceeded.
When this happens, URIs that are not in use are deleted randomly until the
size limit is satisfied, or there are no more URIs that are not in use.
It is possible for the total size on disk to exceed the size limit if all
the URIs are in use.
"""
def __init__(
self,
delete_fn: Optional[Callable[[str, logging.Logger], int]] = None,
max_total_size_bytes: int = DEFAULT_MAX_URI_CACHE_SIZE_BYTES,
debug_mode: bool = False,
):
# Maps URIs to the size in bytes of their corresponding disk contents.
self._used_uris: Set[str] = set()
self._unused_uris: Set[str] = set()
if delete_fn is None:
self._delete_fn = lambda uri, logger: 0
else:
self._delete_fn = delete_fn
# Total size of both used and unused URIs in the cache.
self._total_size_bytes = 0
self.max_total_size_bytes = max_total_size_bytes
# Used in `self._check_valid()` for testing.
self._debug_mode = debug_mode
def mark_unused(self, uri: str, logger: logging.Logger = default_logger):
"""Mark a URI as unused and okay to be deleted."""
if uri not in self._used_uris:
logger.info(f"URI {uri} is already unused.")
else:
self._unused_uris.add(uri)
self._used_uris.remove(uri)
logger.info(f"Marked URI {uri} unused.")
self._evict_if_needed(logger)
self._check_valid()
def mark_used(self, uri: str, logger: logging.Logger = default_logger):
"""Mark a URI as in use. URIs in use will not be deleted."""
if uri in self._used_uris:
return
elif uri in self._unused_uris:
self._used_uris.add(uri)
self._unused_uris.remove(uri)
else:
raise ValueError(
f"Got request to mark URI {uri} used, but this "
"URI is not present in the cache."
)
logger.info(f"Marked URI {uri} used.")
self._check_valid()
def add(self, uri: str, size_bytes: int, logger: logging.Logger = default_logger):
"""Add a URI to the cache and mark it as in use."""
if uri in self._unused_uris:
self._unused_uris.remove(uri)
self._used_uris.add(uri)
self._total_size_bytes += size_bytes
self._evict_if_needed(logger)
self._check_valid()
logger.info(f"Added URI {uri} with size {size_bytes}")
def get_total_size_bytes(self) -> int:
return self._total_size_bytes
def _evict_if_needed(self, logger: logging.Logger = default_logger):
"""Evict unused URIs (if they exist) until total size <= max size."""
while (
self._unused_uris
and self.get_total_size_bytes() > self.max_total_size_bytes
):
# TODO(architkulkarni): Evict least recently used URI instead
arbitrary_unused_uri = next(iter(self._unused_uris))
self._unused_uris.remove(arbitrary_unused_uri)
num_bytes_deleted = self._delete_fn(arbitrary_unused_uri, logger)
self._total_size_bytes -= num_bytes_deleted
logger.info(
f"Deleted URI {arbitrary_unused_uri} with size " f"{num_bytes_deleted}."
)
def _check_valid(self):
"""(Debug mode only) Check "used" and "unused" sets are disjoint."""
if self._debug_mode:
assert self._used_uris & self._unused_uris == set()
def __contains__(self, uri):
return uri in self._used_uris or uri in self._unused_uris
def __repr__(self):
return str(self.__dict__)
+117
View File
@@ -0,0 +1,117 @@
import asyncio
import itertools
import logging
import subprocess
import textwrap
import types
from typing import List
class SubprocessCalledProcessError(subprocess.CalledProcessError):
"""The subprocess.CalledProcessError with stripped stdout."""
LAST_N_LINES = 50
def __init__(self, *args, cmd_index=None, **kwargs):
self.cmd_index = cmd_index
super().__init__(*args, **kwargs)
@staticmethod
def _get_last_n_line(str_data: str, last_n_lines: int) -> str:
if last_n_lines < 0:
return str_data
lines = str_data.strip().split("\n")
return "\n".join(lines[-last_n_lines:])
def __str__(self):
str_list = (
[]
if self.cmd_index is None
else [f"Run cmd[{self.cmd_index}] failed with the following details."]
)
str_list.append(super().__str__())
out = {
"stdout": self.stdout,
"stderr": self.stderr,
}
for name, s in out.items():
if s:
subtitle = f"Last {self.LAST_N_LINES} lines of {name}:"
last_n_line_str = self._get_last_n_line(s, self.LAST_N_LINES).strip()
str_list.append(
f"{subtitle}\n{textwrap.indent(last_n_line_str, ' ' * 4)}"
)
return "\n".join(str_list)
async def check_output_cmd(
cmd: List[str],
*,
logger: logging.Logger,
cmd_index_gen: types.GeneratorType = itertools.count(1),
**kwargs,
) -> str:
"""Run command with arguments and return its output.
If the return code was non-zero it raises a CalledProcessError. The
CalledProcessError object will have the return code in the returncode
attribute and any output in the output attribute.
Args:
cmd: The cmdline should be a sequence of program arguments or else
a single string or path-like object. The program to execute is
the first item in cmd.
logger: The logger instance.
cmd_index_gen: The cmd index generator, default is itertools.count(1).
**kwargs: All arguments are passed to the create_subprocess_exec.
Returns:
The stdout of cmd.
Raises:
CalledProcessError: If the return code of cmd is not 0.
"""
cmd_index = next(cmd_index_gen)
logger.info("Run cmd[%s] %s", cmd_index, repr(cmd))
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
**kwargs,
)
# Use communicate instead of polling stdout:
# * Avoid deadlocks due to streams pausing reading or writing and blocking the
# child process. Please refer to:
# https://docs.python.org/3/library/asyncio-subprocess.html#asyncio.asyncio.subprocess.Process.stderr
# * Avoid mixing multiple outputs of concurrent cmds.
stdout, _ = await proc.communicate()
except asyncio.exceptions.CancelledError as e:
# since Python 3.9, when cancelled, the inner process needs to throw as it is
# for asyncio to timeout properly https://bugs.python.org/issue40607
raise e
except BaseException as e:
raise RuntimeError(f"Run cmd[{cmd_index}] got exception.") from e
else:
stdout = stdout.decode("utf-8")
if stdout:
logger.info("Output of cmd[%s]: %s", cmd_index, stdout)
else:
logger.info("No output for cmd[%s]", cmd_index)
if proc.returncode != 0:
raise SubprocessCalledProcessError(
proc.returncode, cmd, output=stdout, cmd_index=cmd_index
)
return stdout
finally:
if proc is not None:
# Kill process.
try:
proc.kill()
except ProcessLookupError:
pass
# Wait process exit.
await proc.wait()
+344
View File
@@ -0,0 +1,344 @@
"""Util class to install packages via uv."""
import asyncio
import hashlib
import json
import logging
import os
import shutil
import sys
from asyncio import create_task, get_running_loop
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env import dependency_utils, virtualenv_utils
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.utils import check_output_cmd
from ray._private.utils import get_directory_size_bytes
default_logger = logging.getLogger(__name__)
def _get_uv_hash(uv_dict: Dict) -> str:
"""Get a deterministic hash value for `uv` related runtime envs."""
serialized_uv_spec = json.dumps(uv_dict, sort_keys=True)
hash_val = hashlib.sha1(serialized_uv_spec.encode("utf-8")).hexdigest()
return hash_val
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"uv://<hashed_dependencies>"`, or None if no GC required."""
uv = runtime_env.get("uv")
if uv is not None:
if isinstance(uv, dict):
uri = "uv://" + _get_uv_hash(uv_dict=uv)
elif isinstance(uv, list):
uri = "uv://" + _get_uv_hash(uv_dict=dict(packages=uv))
else:
raise TypeError(
"uv field received by RuntimeEnvAgent must be "
f"list or dict, not {type(uv).__name__}."
)
else:
uri = None
return uri
class UvProcessor:
def __init__(
self,
target_dir: str,
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
):
try:
import virtualenv # noqa: F401 ensure virtualenv exists.
except ImportError:
raise RuntimeError(
f"Please install virtualenv "
f"`{sys.executable} -m pip install virtualenv`"
f"to enable uv runtime env."
)
logger.debug("Setting up uv for runtime_env: %s", runtime_env)
self._target_dir = target_dir
# An empty directory is created to execute cmd.
self._exec_cwd = os.path.join(self._target_dir, "exec_cwd")
self._runtime_env = runtime_env
self._logger = logger
self._uv_config = self._runtime_env.uv_config()
self._uv_env = os.environ.copy()
self._uv_env.update(self._runtime_env.env_vars())
async def _install_uv(
self, path: str, cwd: str, pip_env: dict, logger: logging.Logger
):
"""Before package install, make sure the required version `uv` (if specifieds)
is installed.
"""
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
def _get_uv_exec_to_install() -> str:
"""Get `uv` executable with version to install."""
uv_version = self._uv_config.get("uv_version", None)
if uv_version:
return f"uv{uv_version}"
# Use default version.
return "uv"
uv_install_cmd = [
python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-cache-dir",
_get_uv_exec_to_install(),
]
logger.info("Installing package uv to %s", virtualenv_path)
await check_output_cmd(uv_install_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _check_uv_existence(
self, path: str, cwd: str, env: dict, logger: logging.Logger
) -> bool:
"""Check and return the existence of `uv` in virtual env."""
python = virtualenv_utils.get_virtualenv_python(path)
check_existence_cmd = [
python,
"-m",
"uv",
"--version",
]
try:
# If `uv` doesn't exist, exception will be thrown.
await check_output_cmd(check_existence_cmd, logger=logger, cwd=cwd, env=env)
return True
except Exception:
return False
async def _uv_check(sef, python: str, cwd: str, logger: logging.Logger) -> None:
"""Check virtual env dependency compatibility.
If any incompatibility detected, exception will be thrown.
param:
python: the path for python executable within virtual environment.
"""
cmd = [python, "-m", "uv", "pip", "check"]
await check_output_cmd(
cmd,
logger=logger,
cwd=cwd,
)
async def _install_uv_packages(
self,
path: str,
uv_packages: List[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Install required python packages via `uv`."""
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
# TODO(fyrestone): Support -i, --no-deps, --no-cache-dir, ...
requirements_file = dependency_utils.get_requirements_file(path, uv_packages)
# Check existence for `uv` and see if we could skip `uv` installation.
uv_exists = await self._check_uv_existence(path, cwd, pip_env, logger)
# Install uv, which acts as the default package manager.
if (not uv_exists) or (self._uv_config.get("uv_version", None) is not None):
await self._install_uv(path, cwd, pip_env, logger)
# Avoid blocking the event loop.
loop = get_running_loop()
await loop.run_in_executor(
None, dependency_utils.gen_requirements_txt, requirements_file, uv_packages
)
# Install all dependencies.
#
# Difference with pip:
# 1. `--disable-pip-version-check` has no effect for uv.
uv_install_cmd = [
python,
"-m",
"uv",
"pip",
"install",
"-r",
requirements_file,
]
uv_opt_list = self._uv_config.get("uv_pip_install_options", ["--no-cache"])
if uv_opt_list:
uv_install_cmd += uv_opt_list
logger.info("Installing python requirements to %s", virtualenv_path)
await check_output_cmd(uv_install_cmd, logger=logger, cwd=cwd, env=pip_env)
# Check python environment for conflicts.
if self._uv_config.get("uv_check", False):
await self._uv_check(python, cwd, logger)
async def _run(self):
path = self._target_dir
logger = self._logger
uv_packages = self._uv_config["packages"]
# We create an empty directory for exec cmd so that the cmd will
# run more stable. e.g. if cwd has ray, then checking ray will
# look up ray in cwd instead of site packages.
os.makedirs(self._exec_cwd, exist_ok=True)
try:
await virtualenv_utils.create_or_get_virtualenv(
path, self._exec_cwd, logger
)
python = virtualenv_utils.get_virtualenv_python(path)
async with dependency_utils.check_ray(python, self._exec_cwd, logger):
# Install packages with uv.
await self._install_uv_packages(
path,
uv_packages,
self._exec_cwd,
self._uv_env,
logger,
)
except Exception:
logger.info("Delete incomplete virtualenv: %s", path)
shutil.rmtree(path, ignore_errors=True)
logger.exception("Failed to install uv packages.")
raise
def __await__(self):
return self._run().__await__()
class UvPlugin(RuntimeEnvPlugin):
name = "uv"
def __init__(self, resources_dir: str):
self._uv_resource_dir = os.path.join(resources_dir, "uv")
self._creating_task = {}
# Maps a URI to a lock that is used to prevent multiple concurrent
# installs of the same virtualenv, see #24513
self._create_locks: Dict[str, asyncio.Lock] = {}
# Key: created hashes. Value: size of the uv dir.
self._created_hash_bytes: Dict[str, int] = {}
try_to_create_directory(self._uv_resource_dir)
def _get_path_from_hash(self, hash_val: str) -> str:
"""Generate a path from the hash of a uv spec.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/uv/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._uv_resource_dir, hash_val)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the uv URI from the RuntimeEnv if it exists, else return []."""
uv_uri = runtime_env.uv_uri()
if uv_uri:
return [uv_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete uv URI %s", uri)
protocol, hash_val = parse_uri(uri)
if protocol != Protocol.UV:
raise ValueError(
"UvPlugin can only delete URIs with protocol "
f"uv. Received protocol {protocol}, URI {uri}"
)
# Cancel running create task.
task = self._creating_task.pop(hash_val, None)
if task is not None:
task.cancel()
del self._created_hash_bytes[hash_val]
uv_env_path = self._get_path_from_hash(hash_val)
local_dir_size = get_directory_size_bytes(uv_env_path)
del self._create_locks[uri]
try:
shutil.rmtree(uv_env_path)
except OSError as e:
logger.warning(f"Error when deleting uv env {uv_env_path}: {str(e)}")
return 0
return local_dir_size
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not runtime_env.has_uv():
return 0
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
async def _create_for_hash():
await UvProcessor(
target_dir,
runtime_env,
logger,
)
loop = get_running_loop()
return await loop.run_in_executor(
None, get_directory_size_bytes, target_dir
)
if uri not in self._create_locks:
# async lock to prevent the same virtualenv being concurrently installed
self._create_locks[uri] = asyncio.Lock()
async with self._create_locks[uri]:
if hash_val in self._created_hash_bytes:
return self._created_hash_bytes[hash_val]
self._creating_task[hash_val] = task = create_task(_create_for_hash())
task.add_done_callback(lambda _: self._creating_task.pop(hash_val, None))
uv_dir_bytes = await task
self._created_hash_bytes[hash_val] = uv_dir_bytes
return uv_dir_bytes
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: logging.Logger = default_logger,
):
if not runtime_env.has_uv():
return
# UvPlugin only uses a single URI.
uri = uris[0]
# Update py_executable.
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
virtualenv_python = virtualenv_utils.get_virtualenv_python(target_dir)
if not os.path.exists(virtualenv_python):
raise ValueError(
f"Local directory {target_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"installing the runtime_env `uv` packages."
)
context.py_executable = virtualenv_python
context.command_prefix += virtualenv_utils.get_virtualenv_activate_command(
target_dir
)
@@ -0,0 +1,452 @@
import argparse
import copy
import optparse
import os
import pathlib
import platform
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import psutil
def _is_path(path_or_uri: str) -> bool:
"""Returns True if uri_or_path is a path and False otherwise.
IMPORTANT: This is a duplicate of ray._private.path_utils.is_path().
Why we can't import from path_utils:
- This hook runs via `uv run --no-project uv_runtime_env_hook.py` in test scenarios
- UV creates a minimal environment without dependencies installed yet
- Importing from ray._private.path_utils triggers the full Ray import chain:
ray._private.path_utils → ray/__init__.py → ray._private.worker →
ray.widgets → ray.widgets.util → packaging.version
- The 'packaging' module is not available in the minimal UV environment,
causing: ModuleNotFoundError: No module named 'packaging.version'
This duplicate implementation uses only stdlib (pathlib, urllib.parse)
to avoid the dependency issue. If you modify this function, ensure you
also update ray._private.path_utils.is_path() to keep them in sync.
"""
if not isinstance(path_or_uri, str):
raise TypeError(f"path_or_uri must be a string, got {type(path_or_uri)}.")
parsed_path = pathlib.Path(path_or_uri)
parsed_uri = urllib.parse.urlparse(path_or_uri)
if isinstance(parsed_path, pathlib.PurePosixPath):
return not parsed_uri.scheme
elif isinstance(parsed_path, pathlib.PureWindowsPath):
return parsed_uri.scheme == parsed_path.drive.strip(":").lower()
else:
# this should never happen
raise TypeError(f"Unsupported path type: {type(parsed_path).__name__}")
def _create_uv_run_parser():
"""Create and return the argument parser for 'uv run' command."""
parser = optparse.OptionParser(prog="uv run", add_help_option=False)
# Disable interspersed args to stop parsing when we hit the first
# argument that is not recognized by the parser.
parser.disable_interspersed_args()
# Main options group
main_group = optparse.OptionGroup(parser, "Main options")
main_group.add_option("--extra", action="append", dest="extras")
main_group.add_option("--all-extras", action="store_true")
main_group.add_option("--no-extra", action="append", dest="no_extras")
main_group.add_option("--no-dev", action="store_true")
main_group.add_option("--group", action="append", dest="groups")
main_group.add_option("--no-group", action="append", dest="no_groups")
main_group.add_option("--no-default-groups", action="store_true")
main_group.add_option("--only-group", action="append", dest="only_groups")
main_group.add_option("--all-groups", action="store_true")
main_group.add_option("-m", "--module")
main_group.add_option("--only-dev", action="store_true")
main_group.add_option("--no-editable", action="store_true")
main_group.add_option("--exact", action="store_true")
main_group.add_option("--env-file", action="append", dest="env_files")
main_group.add_option("--no-env-file", action="store_true")
parser.add_option_group(main_group)
# With options
with_group = optparse.OptionGroup(parser, "With options")
with_group.add_option("--with", action="append", dest="with_packages")
with_group.add_option("--with-editable", action="append", dest="with_editable")
with_group.add_option(
"--with-requirements", action="append", dest="with_requirements"
)
parser.add_option_group(with_group)
# Environment options
env_group = optparse.OptionGroup(parser, "Environment options")
env_group.add_option("--isolated", action="store_true")
env_group.add_option("--active", action="store_true")
env_group.add_option("--no-sync", action="store_true")
env_group.add_option("--locked", action="store_true")
env_group.add_option("--frozen", action="store_true")
parser.add_option_group(env_group)
# Script options
script_group = optparse.OptionGroup(parser, "Script options")
script_group.add_option("-s", "--script", action="store_true")
script_group.add_option("--gui-script", action="store_true")
parser.add_option_group(script_group)
# Workspace options
workspace_group = optparse.OptionGroup(parser, "Workspace options")
workspace_group.add_option("--all-packages", action="store_true")
workspace_group.add_option("--package")
workspace_group.add_option("--no-project", action="store_true")
parser.add_option_group(workspace_group)
# Index options
index_group = optparse.OptionGroup(parser, "Index options")
index_group.add_option("--index", action="append", dest="indexes")
index_group.add_option("--default-index")
index_group.add_option("-i", "--index-url")
index_group.add_option(
"--extra-index-url", action="append", dest="extra_index_urls"
)
index_group.add_option("-f", "--find-links", action="append", dest="find_links")
index_group.add_option("--no-index", action="store_true")
index_group.add_option(
"--index-strategy",
type="choice",
choices=["first-index", "unsafe-first-match", "unsafe-best-match"],
)
index_group.add_option(
"--keyring-provider", type="choice", choices=["disabled", "subprocess"]
)
parser.add_option_group(index_group)
# Resolver options
resolver_group = optparse.OptionGroup(parser, "Resolver options")
resolver_group.add_option("-U", "--upgrade", action="store_true")
resolver_group.add_option(
"-P", "--upgrade-package", action="append", dest="upgrade_packages"
)
resolver_group.add_option(
"--resolution", type="choice", choices=["highest", "lowest", "lowest-direct"]
)
resolver_group.add_option(
"--prerelease",
type="choice",
choices=[
"disallow",
"allow",
"if-necessary",
"explicit",
"if-necessary-or-explicit",
],
)
resolver_group.add_option(
"--fork-strategy", type="choice", choices=["fewest", "requires-python"]
)
resolver_group.add_option("--exclude-newer")
resolver_group.add_option("--no-sources", action="store_true")
parser.add_option_group(resolver_group)
# Installer options
installer_group = optparse.OptionGroup(parser, "Installer options")
installer_group.add_option("--reinstall", action="store_true")
installer_group.add_option(
"--reinstall-package", action="append", dest="reinstall_packages"
)
installer_group.add_option(
"--link-mode", type="choice", choices=["clone", "copy", "hardlink", "symlink"]
)
installer_group.add_option("--compile-bytecode", action="store_true")
parser.add_option_group(installer_group)
# Build options
build_group = optparse.OptionGroup(parser, "Build options")
build_group.add_option(
"-C", "--config-setting", action="append", dest="config_settings"
)
build_group.add_option("--no-build-isolation", action="store_true")
build_group.add_option(
"--no-build-isolation-package",
action="append",
dest="no_build_isolation_packages",
)
build_group.add_option("--no-build", action="store_true")
build_group.add_option(
"--no-build-package", action="append", dest="no_build_packages"
)
build_group.add_option("--no-binary", action="store_true")
build_group.add_option(
"--no-binary-package", action="append", dest="no_binary_packages"
)
parser.add_option_group(build_group)
# Cache options
cache_group = optparse.OptionGroup(parser, "Cache options")
cache_group.add_option("-n", "--no-cache", action="store_true")
cache_group.add_option("--cache-dir")
cache_group.add_option("--refresh", action="store_true")
cache_group.add_option(
"--refresh-package", action="append", dest="refresh_packages"
)
parser.add_option_group(cache_group)
# Python options
python_group = optparse.OptionGroup(parser, "Python options")
python_group.add_option("-p", "--python")
python_group.add_option("--managed-python", action="store_true")
python_group.add_option("--no-managed-python", action="store_true")
python_group.add_option("--no-python-downloads", action="store_true")
# note: the following is a legacy option and will be removed at some point
# https://github.com/astral-sh/uv/pull/12246
python_group.add_option(
"--python-preference",
type="choice",
choices=["only-managed", "managed", "system", "only-system"],
)
parser.add_option_group(python_group)
# Global options
global_group = optparse.OptionGroup(parser, "Global options")
global_group.add_option("-q", "--quiet", action="count", default=0)
global_group.add_option("-v", "--verbose", action="count", default=0)
global_group.add_option(
"--color", type="choice", choices=["auto", "always", "never"]
)
global_group.add_option("--native-tls", action="store_true")
global_group.add_option("--offline", action="store_true")
global_group.add_option(
"--allow-insecure-host", action="append", dest="insecure_hosts"
)
global_group.add_option("--no-progress", action="store_true")
global_group.add_option("--directory")
global_group.add_option("--project")
global_group.add_option("--config-file")
global_group.add_option("--no-config", action="store_true")
parser.add_option_group(global_group)
return parser
def _parse_args(
parser: optparse.OptionParser, args: List[str]
) -> Tuple[optparse.Values, List[str]]:
"""
Parse the command-line options found in 'args'.
Replacement for parser.parse_args that handles unknown arguments
by keeping them in the command list instead of erroring and
discarding them.
"""
parser.rargs = args
parser.largs = []
options = parser.get_default_values()
try:
parser._process_args(parser.largs, parser.rargs, options)
except optparse.BadOptionError as err:
# If we hit an argument that is not recognized, we put it
# back into the unconsumed arguments
parser.rargs = [err.opt_str] + parser.rargs
return options, parser.rargs
def _check_working_dir_files(
uv_run_args: optparse.Values, runtime_env: Dict[str, Any]
) -> None:
"""
Check that the files required by uv are local to the working_dir. This catches
the most common cases of how things are different in Ray, i.e. not the whole file
system will be available on the workers, only the working_dir.
The function won't return anything, it just raises a RuntimeError if there is an error.
"""
working_dir = Path(runtime_env["working_dir"]).resolve()
# Check if the requirements.txt file is in the working_dir
if uv_run_args.with_requirements:
for requirements_file in uv_run_args.with_requirements:
if not Path(requirements_file).resolve().is_relative_to(working_dir):
raise RuntimeError(
f"You specified --with-requirements={uv_run_args.with_requirements} but "
f"the requirements file is not in the working_dir {runtime_env['working_dir']}, "
"so the workers will not have access to the file. Make sure "
"the requirements file is in the working directory. "
"You can do so by specifying --directory in 'uv run', by changing the current "
"working directory before running 'uv run', or by using the 'working_dir' "
"parameter of the runtime_environment."
)
# Check if the pyproject.toml file is in the working_dir
pyproject = None
if uv_run_args.no_project:
pyproject = None
elif uv_run_args.project:
pyproject = Path(uv_run_args.project)
else:
# Walk up the directory tree until pyproject.toml is found
current_path = Path.cwd().resolve()
while current_path != current_path.parent:
if (current_path / "pyproject.toml").exists():
pyproject = Path(current_path / "pyproject.toml")
break
current_path = current_path.parent
if pyproject and not pyproject.resolve().is_relative_to(working_dir):
raise RuntimeError(
f"Your {pyproject.resolve()} is not in the working_dir {runtime_env['working_dir']}, "
"so the workers will not have access to the file. Make sure "
"the pyproject.toml file is in the working directory. "
"You can do so by specifying --directory in 'uv run', by changing the current "
"working directory before running 'uv run', or by using the 'working_dir' "
"parameter of the runtime_environment."
)
def _get_uv_run_cmdline() -> Optional[List[str]]:
"""
Return the command line of the first ancestor process that was run with
"uv run" and None if there is no such ancestor.
uv spawns the python process as a child process, so we first check the
parent process command line. We also check our parent's parents since
the Ray driver might be run as a subprocess of the 'uv run' process.
"""
parents = psutil.Process().parents()
for parent in parents:
try:
cmdline = parent.cmdline()
if (
len(cmdline) > 1
and os.path.basename(cmdline[0]) == "uv"
and cmdline[1] == "run"
):
return cmdline
except psutil.NoSuchProcess:
continue
except psutil.AccessDenied:
continue
return None
def hook(runtime_env: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Hook that detects if the driver is run in 'uv run' and sets the runtime environment accordingly."""
runtime_env = copy.deepcopy(runtime_env) or {}
cmdline = _get_uv_run_cmdline()
if not cmdline:
# This means the driver was not run in a 'uv run' environment -- in this case
# we leave the runtime environment unchanged
return runtime_env
# First check that the "uv" and "pip" runtime environments are not used.
if "uv" in runtime_env or "pip" in runtime_env:
raise RuntimeError(
"You are using the 'pip' or 'uv' runtime environments together with "
"'uv run'. These are not compatible since 'uv run' will run the workers "
"in an isolated environment -- please add the 'pip' or 'uv' dependencies to your "
"'uv run' environment e.g. by including them in your pyproject.toml."
)
# Extract the arguments uv_run_args of 'uv run' that are not part of the command.
args_to_parse = cmdline[2:] # Remove 'uv run' prefix
original_length = len(
args_to_parse
) # Save before parsing (parser modifies in-place)
parser = _create_uv_run_parser()
(options, command) = _parse_args(parser, args_to_parse)
# Calculate how many arguments were consumed by the parser.
# Since disable_interspersed_args() is set, parsing stops at the first
# unrecognized argument (the command), so all consumed args are uv options.
args_consumed = original_length - len(command)
uv_run_args = cmdline[: 2 + args_consumed]
# Remove the "--directory" argument since it has already been taken into
# account when setting the current working directory of the current process.
# Also remove the "--module" argument, since the default_worker.py is
# invoked as a script and not as a module.
parser = argparse.ArgumentParser()
parser.add_argument("--directory")
parser.add_argument("-m", "--module")
_, remaining_uv_run_args = parser.parse_known_args(uv_run_args)
# Pin the worker Python for `uv run` unless the driver already specified one.
#
# Without this, uv may resolve a different Python than the Ray driver (e.g. 3.12 instead of 3.11),
# which causes Ray to fail with a Python version mismatch. (See https://github.com/ray-project/ray/issues/59639)
# order of precedence:
# 1. options.python (driver specified)
# 2. env_vars["UV_PYTHON"]
# 3. current os.environ["UV_PYTHON"]
# 4. platform.python_version() (since uv run uses the same Python as the driver)
if not options.python:
env_vars = runtime_env.get("env_vars") or {}
uv_python = (
env_vars.get("UV_PYTHON")
or os.environ.get("UV_PYTHON")
or platform.python_version()
)
remaining_uv_run_args = remaining_uv_run_args + ["--python", uv_python]
# Append "python" to the end so that when Ray adds "-m default_worker.py",
# it becomes "uv run --python X.Y.Z python -m default_worker.py"
remaining_uv_run_args = remaining_uv_run_args + ["python"]
runtime_env["py_executable"] = " ".join(remaining_uv_run_args)
# If the user specified a working_dir, we always honor it, otherwise
# use the same working_dir that uv run would use
if "working_dir" not in runtime_env:
runtime_env["working_dir"] = os.getcwd()
# Validate that pyproject.toml and requirements files are within working_dir
# This prevents runtime errors on workers when files are not accessible
# Only validate for local paths - remote URIs will be downloaded by Ray
working_dir = runtime_env["working_dir"]
if _is_path(working_dir):
_check_working_dir_files(options, runtime_env)
return runtime_env
# This __main__ is used for unit testing if the runtime_env_hook picks up the
# right settings.
if __name__ == "__main__":
import json
test_parser = argparse.ArgumentParser()
test_parser.add_argument("--extra-args", action="store_true")
test_parser.add_argument("runtime_env")
args = test_parser.parse_args()
# If the env variable is set, add one more level of subprocess indirection
if os.environ.get("RAY_TEST_UV_ADD_SUBPROCESS_INDIRECTION") == "1":
import subprocess
env = os.environ.copy()
env.pop("RAY_TEST_UV_ADD_SUBPROCESS_INDIRECTION")
subprocess.check_call([sys.executable] + sys.argv, env=env)
sys.exit(0)
# If the following env variable is set, we use multiprocessing
# spawn to start the subprocess, since it uses a different way to
# modify the command line than subprocess.check_call
if os.environ.get("RAY_TEST_UV_MULTIPROCESSING_SPAWN") == "1":
import multiprocessing
multiprocessing.set_start_method("spawn")
pool = multiprocessing.Pool(processes=1)
runtime_env = json.loads(args.runtime_env)
print(json.dumps(pool.apply(hook, (runtime_env,))))
sys.exit(0)
# We purposefully modify sys.argv here to make sure the hook is robust
# against such modification.
sys.argv.pop(1)
runtime_env = json.loads(args.runtime_env)
print(json.dumps(hook(runtime_env)))
@@ -0,0 +1,466 @@
import logging
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Dict, List, Optional, Union
import yaml
from ray._private.path_utils import is_path
from ray._private.runtime_env.packaging import parse_path
logger = logging.getLogger(__name__)
def validate_path(path: str) -> None:
"""Parse the path to ensure it is well-formed and exists."""
parse_path(path)
def validate_uri(uri: str):
try:
from ray._private.runtime_env.packaging import Protocol, parse_uri
protocol, path = parse_uri(uri)
except ValueError:
raise ValueError(
f"{uri} is not a valid URI. Passing directories or modules to "
"be dynamically uploaded is only supported at the job level "
"(i.e., passed to `ray.init`)."
)
supported_extensions = (".zip", ".whl", ".tar.gz", ".tgz")
if protocol in Protocol.remote_protocols() and not any(
path.endswith(ext) for ext in supported_extensions
):
raise ValueError(
"Only .zip, .whl, .tar.gz, and .tgz files supported for remote URIs."
)
def _handle_local_deps_requirement_file(requirements_file: str):
"""Read the given [requirements_file], and return all required dependencies."""
requirements_path = Path(requirements_file)
if not requirements_path.is_file():
raise ValueError(f"{requirements_path} is not a valid file")
return requirements_path.read_text().strip().split("\n")
def validate_py_modules_uris(py_modules_uris: List[str]) -> List[str]:
"""Parses and validates a 'py_modules' option.
Expects py_modules to be a list of URIs.
"""
if not isinstance(py_modules_uris, list):
raise TypeError(
"`py_modules` must be a list of strings, got " f"{type(py_modules_uris)}."
)
for module in py_modules_uris:
if not isinstance(module, str):
raise TypeError("`py_module` must be a string, got " f"{type(module)}.")
validate_uri(module)
def parse_and_validate_py_modules(py_modules: List[str]) -> List[str]:
"""Parses and validates a 'py_modules' option.
Expects py_modules to be a list of local paths or URIs.
"""
if not isinstance(py_modules, list):
raise TypeError(
"`py_modules` must be a list of strings, got " f"{type(py_modules)}."
)
for module in py_modules:
if not isinstance(module, str):
raise TypeError("`py_module` must be a string, got " f"{type(module)}.")
if is_path(module):
validate_path(module)
else:
validate_uri(module)
return py_modules
def validate_working_dir_uri(working_dir_uri: str) -> str:
"""Parses and validates a 'working_dir' option."""
if not isinstance(working_dir_uri, str):
raise TypeError(
"`working_dir` must be a string, got " f"{type(working_dir_uri)}."
)
validate_uri(working_dir_uri)
def parse_and_validate_working_dir(working_dir: str) -> str:
"""Parses and validates a 'working_dir' option.
This can be a URI or a path.
"""
assert working_dir is not None
if not isinstance(working_dir, str):
raise TypeError("`working_dir` must be a string, got " f"{type(working_dir)}.")
if is_path(working_dir):
validate_path(working_dir)
else:
validate_uri(working_dir)
return working_dir
def parse_and_validate_conda(conda: Union[str, dict]) -> Union[str, dict]:
"""Parses and validates a user-provided 'conda' option.
Conda can be one of three cases:
1) A dictionary describing the env. This is passed through directly.
2) A string referring to the name of a preinstalled conda env.
3) A string pointing to a local conda YAML file. This is detected
by looking for a '.yaml' or '.yml' suffix. In this case, the file
will be read as YAML and passed through as a dictionary.
"""
assert conda is not None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
result = conda
if isinstance(conda, str):
file_path = Path(conda)
if file_path.suffix in (".yaml", ".yml"):
if not file_path.is_file():
raise ValueError(f"Can't find conda YAML file {file_path}.")
try:
result = yaml.safe_load(file_path.read_text())
except Exception as e:
raise ValueError(f"Failed to read conda file {file_path}: {e}.")
elif file_path.is_absolute():
if not file_path.is_dir():
raise ValueError(f"Can't find conda env directory {file_path}.")
result = str(file_path)
elif isinstance(conda, dict):
result = conda
else:
raise TypeError(
"runtime_env['conda'] must be of type str or " f"dict, got {type(conda)}."
)
return result
def parse_and_validate_uv(uv: Union[str, List[str], Dict]) -> Optional[Dict]:
"""Parses and validates a user-provided 'uv' option.
The value of the input 'uv' field can be one of two cases:
1) A List[str] describing the requirements. This is passed through.
Example usage: ["tensorflow", "requests"]
2) a string containing the path to a local pip “requirements.txt” file.
3) A python dictionary that has one field:
a) packages (required, List[str]): a list of uv packages, it same as 1).
b) uv_check (optional, bool): whether to enable pip check at the end of uv
install, default to False.
c) uv_version (optional, str): user provides a specific uv to use; if
unspecified, default version of uv will be used.
d) uv_pip_install_options (optional, List[str]): user-provided options for
`uv pip install` command, default to ["--no-cache"].
The returned parsed value will be a list of packages. If a Ray library
(e.g. "ray[serve]") is specified, it will be deleted and replaced by its
dependencies (e.g. "uvicorn", "requests").
"""
assert uv is not None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
result: str = ""
if isinstance(uv, str):
uv_list = _handle_local_deps_requirement_file(uv)
result = dict(packages=uv_list, uv_check=False)
elif isinstance(uv, list) and all(isinstance(dep, str) for dep in uv):
result = dict(packages=uv, uv_check=False)
elif isinstance(uv, dict):
if set(uv.keys()) - {
"packages",
"uv_check",
"uv_version",
"uv_pip_install_options",
}:
raise ValueError(
"runtime_env['uv'] can only have these fields: "
"packages, uv_check, uv_version and uv_pip_install_options, but got: "
f"{list(uv.keys())}"
)
if "packages" not in uv:
raise ValueError(
f"runtime_env['uv'] must include field 'packages', but got {uv}"
)
if "uv_check" in uv and not isinstance(uv["uv_check"], bool):
raise TypeError(
"runtime_env['uv']['uv_check'] must be of type bool, "
f"got {type(uv['uv_check'])}"
)
if "uv_version" in uv and not isinstance(uv["uv_version"], str):
raise TypeError(
"runtime_env['uv']['uv_version'] must be of type str, "
f"got {type(uv['uv_version'])}"
)
if "uv_pip_install_options" in uv:
if not isinstance(uv["uv_pip_install_options"], list):
raise TypeError(
"runtime_env['uv']['uv_pip_install_options'] must be of type "
f"list[str] got {type(uv['uv_pip_install_options'])}"
)
# Check each item in installation option.
for idx, cur_opt in enumerate(uv["uv_pip_install_options"]):
if not isinstance(cur_opt, str):
raise TypeError(
"runtime_env['uv']['uv_pip_install_options'] must be of type "
f"list[str] got {type(cur_opt)} for {idx}-th item."
)
result = uv.copy()
result["uv_check"] = uv.get("uv_check", False)
result["uv_pip_install_options"] = uv.get(
"uv_pip_install_options", ["--no-cache"]
)
if not isinstance(uv["packages"], list):
raise ValueError(
"runtime_env['uv']['packages'] must be of type list, "
f"got: {type(uv['packages'])}"
)
else:
raise TypeError(
"runtime_env['uv'] must be of type " f"List[str], or dict, got {type(uv)}"
)
# Deduplicate packages for package lists.
result["packages"] = list(OrderedDict.fromkeys(result["packages"]))
if len(result["packages"]) == 0:
result = None
logger.debug(f"Rewrote runtime_env `uv` field from {uv} to {result}.")
return result
def parse_and_validate_pip(pip: Union[str, List[str], Dict]) -> Optional[Dict]:
"""Parses and validates a user-provided 'pip' option.
The value of the input 'pip' field can be one of two cases:
1) A List[str] describing the requirements. This is passed through.
2) A string pointing to a local requirements file. In this case, the
file contents will be read split into a list.
3) A python dictionary that has three fields:
a) packages (required, List[str]): a list of pip packages, it same as 1).
b) pip_check (optional, bool): whether to enable pip check at the end of pip
install, default to False.
c) pip_version (optional, str): the version of pip, ray will spell
the package name 'pip' in front of the `pip_version` to form the final
requirement string, the syntax of a requirement specifier is defined in
full in PEP 508.
d) pip_install_options (optional, List[str]): user-provided options for
`pip install` command, defaults to ["--disable-pip-version-check", "--no-cache-dir"].
The returned parsed value will be a list of pip packages. If a Ray library
(e.g. "ray[serve]") is specified, it will be deleted and replaced by its
dependencies (e.g. "uvicorn", "requests").
"""
assert pip is not None
result = None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
if isinstance(pip, str):
# We have been given a path to a requirements.txt file.
pip_list = _handle_local_deps_requirement_file(pip)
result = dict(
packages=pip_list,
pip_check=False,
)
elif isinstance(pip, list) and all(isinstance(dep, str) for dep in pip):
result = dict(packages=pip, pip_check=False)
elif isinstance(pip, dict):
if set(pip.keys()) - {
"packages",
"pip_check",
"pip_install_options",
"pip_version",
}:
raise ValueError(
"runtime_env['pip'] can only have these fields: "
"packages, pip_check, pip_install_options and pip_version, but got: "
f"{list(pip.keys())}"
)
if "pip_check" in pip and not isinstance(pip["pip_check"], bool):
raise TypeError(
"runtime_env['pip']['pip_check'] must be of type bool, "
f"got {type(pip['pip_check'])}"
)
if "pip_version" in pip:
if not isinstance(pip["pip_version"], str):
raise TypeError(
"runtime_env['pip']['pip_version'] must be of type str, "
f"got {type(pip['pip_version'])}"
)
if "pip_install_options" in pip:
if not isinstance(pip["pip_install_options"], list):
raise TypeError(
"runtime_env['pip']['pip_install_options'] must be of type "
f"list[str] got {type(pip['pip_install_options'])}"
)
# Check each item in installation option.
for idx, cur_opt in enumerate(pip["pip_install_options"]):
if not isinstance(cur_opt, str):
raise TypeError(
"runtime_env['pip']['pip_install_options'] must be of type "
f"list[str] got {type(cur_opt)} for {idx}-th item."
)
result = pip.copy()
# Contrary to pip_check, we do not insert the default value of pip_install_options.
# This is to maintain backwards compatibility with ray==2.0.1
result["pip_check"] = pip.get("pip_check", False)
if "packages" not in pip:
raise ValueError(
f"runtime_env['pip'] must include field 'packages', but got {pip}"
)
elif isinstance(pip["packages"], str):
result["packages"] = _handle_local_deps_requirement_file(pip["packages"])
elif not isinstance(pip["packages"], list):
raise ValueError(
"runtime_env['pip']['packages'] must be of type str of list, "
f"got: {type(pip['packages'])}"
)
else:
raise TypeError(
"runtime_env['pip'] must be of type str or " f"List[str], got {type(pip)}"
)
# Eliminate duplicates to prevent `pip install` from erroring. Use
# OrderedDict to preserve the order of the list. This makes the output
# deterministic and easier to debug, because pip install can have
# different behavior depending on the order of the input.
result["packages"] = list(OrderedDict.fromkeys(result["packages"]))
if len(result["packages"]) == 0:
result = None
logger.debug(f"Rewrote runtime_env `pip` field from {pip} to {result}.")
return result
def parse_and_validate_container(container: List[str]) -> List[str]:
"""Parses and validates a user-provided 'container' option.
This is passed through without validation (for now).
"""
assert container is not None
return container
def parse_and_validate_excludes(excludes: List[str]) -> List[str]:
"""Parses and validates a user-provided 'excludes' option.
This is validated to verify that it is of type List[str].
If an empty list is passed, we return `None` for consistency.
"""
assert excludes is not None
if isinstance(excludes, list) and len(excludes) == 0:
return None
if isinstance(excludes, list) and all(isinstance(path, str) for path in excludes):
return excludes
else:
raise TypeError(
"runtime_env['excludes'] must be of type "
f"List[str], got {type(excludes)}"
)
def parse_and_validate_env_vars(env_vars: Dict[str, str]) -> Optional[Dict[str, str]]:
"""Parses and validates a user-provided 'env_vars' option.
This is validated to verify that all keys and vals are strings.
If an empty dictionary is passed, we return `None` for consistency.
Args:
env_vars: A dictionary of environment variables to set in the
runtime environment.
Returns:
The validated env_vars dictionary, or None if it was empty.
Raises:
TypeError: If the env_vars is not a dictionary of strings. The error message
will include the type of the invalid value.
"""
assert env_vars is not None
if len(env_vars) == 0:
return None
if not isinstance(env_vars, dict):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], got {type(env_vars)}"
)
for key, val in env_vars.items():
if not isinstance(key, str):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], but the key {key} is of type {type(key)}"
)
if not isinstance(val, str):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], but the value {val} is of type {type(val)}"
)
return env_vars
# Dictionary mapping runtime_env options with the function to parse and
# validate them.
OPTION_TO_VALIDATION_FN = {
"py_modules": parse_and_validate_py_modules,
"working_dir": parse_and_validate_working_dir,
"excludes": parse_and_validate_excludes,
"conda": parse_and_validate_conda,
"pip": parse_and_validate_pip,
"uv": parse_and_validate_uv,
"env_vars": parse_and_validate_env_vars,
"container": parse_and_validate_container,
}
# RuntimeEnv can be created with local paths
# for these options. However, after the packages
# for these options have been uploaded to GCS,
# they must be URIs. These functions provide the ability
# to validate that these options only contain well-formed URIs.
OPTION_TO_NO_PATH_VALIDATION_FN = {
"working_dir": validate_working_dir_uri,
"py_modules": validate_py_modules_uris,
}

Some files were not shown because too many files have changed in this diff Show More