chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
# isort: skip_file
|
||||
from ray._private import log # isort: skip # noqa: F401
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
log.generate_logging_config()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _configure_system():
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
"""Wraps system configuration to avoid 'leaking' variables into ray."""
|
||||
|
||||
# Sanity check pickle5 if it has been installed.
|
||||
if "pickle5" in sys.modules:
|
||||
if sys.version_info >= (3, 8):
|
||||
logger.warning(
|
||||
"Package pickle5 becomes unnecessary in Python 3.8 and above. "
|
||||
"Its presence may confuse libraries including Ray. "
|
||||
"Please uninstall the package."
|
||||
)
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
try:
|
||||
version_str = importlib.metadata.version("pickle5")
|
||||
version = tuple(int(n) for n in version_str.split("."))
|
||||
if version < (0, 0, 10):
|
||||
logger.warning(
|
||||
"Although not used by Ray, a version of pickle5 that leaks memory "
|
||||
"is found in the environment. Please run 'pip install pickle5 -U' "
|
||||
"to upgrade."
|
||||
)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.warning(
|
||||
"You are using the 'pickle5' module, but "
|
||||
"the exact version is unknown (possibly carried as "
|
||||
"an internal component by another module). Please "
|
||||
"make sure you are using pickle5 >= 0.0.10 because "
|
||||
"previous versions may leak memory."
|
||||
)
|
||||
|
||||
# Importing psutil. Must be before ray._raylet is
|
||||
# initialized.
|
||||
thirdparty_files = os.path.join(
|
||||
os.path.abspath(os.path.dirname(__file__)), "thirdparty_files"
|
||||
)
|
||||
sys.path.insert(0, thirdparty_files)
|
||||
|
||||
if (
|
||||
platform.system() == "Linux"
|
||||
and "Microsoft".lower() in platform.release().lower()
|
||||
):
|
||||
from ray._private import compat # noqa: E402
|
||||
|
||||
compat.patch_psutil()
|
||||
|
||||
# Expose ray ABI symbols which may be dependent by other shared
|
||||
# libraries such as _streaming.so. See BUILD.bazel:_raylet
|
||||
python_shared_lib_suffix = ".so" if sys.platform != "win32" else ".pyd"
|
||||
so_path = os.path.join(
|
||||
os.path.dirname(__file__), "_raylet" + python_shared_lib_suffix
|
||||
)
|
||||
if os.path.exists(so_path):
|
||||
import ctypes
|
||||
from ctypes import CDLL
|
||||
|
||||
CDLL(so_path, ctypes.RTLD_GLOBAL)
|
||||
|
||||
|
||||
_configure_system()
|
||||
# Delete configuration function.
|
||||
del _configure_system
|
||||
|
||||
from ray import _version # noqa: E402
|
||||
|
||||
__commit__ = _version.commit
|
||||
__version__ = _version.version
|
||||
|
||||
import ray._raylet # noqa: E402
|
||||
|
||||
from ray._raylet import ( # noqa: E402,F401
|
||||
ActorClassID,
|
||||
ActorID,
|
||||
NodeID,
|
||||
Config as _Config,
|
||||
JobID,
|
||||
WorkerID,
|
||||
FunctionID,
|
||||
ObjectID,
|
||||
ObjectRef,
|
||||
ObjectRefGenerator,
|
||||
DynamicObjectRefGenerator,
|
||||
TaskID,
|
||||
UniqueID,
|
||||
Language,
|
||||
PlacementGroupID,
|
||||
ClusterID,
|
||||
)
|
||||
|
||||
_config = _Config()
|
||||
|
||||
from ray._private.state import ( # noqa: E402,F401
|
||||
nodes,
|
||||
timeline,
|
||||
cluster_resources,
|
||||
available_resources,
|
||||
)
|
||||
from ray._private.worker import ( # noqa: E402,F401
|
||||
SCRIPT_MODE,
|
||||
WORKER_MODE,
|
||||
RESTORE_WORKER_MODE,
|
||||
SPILL_WORKER_MODE,
|
||||
cancel,
|
||||
get,
|
||||
get_actor,
|
||||
get_gpu_ids,
|
||||
init,
|
||||
is_initialized,
|
||||
put,
|
||||
kill,
|
||||
remote,
|
||||
shutdown,
|
||||
wait,
|
||||
)
|
||||
|
||||
from ray._private.ray_logging.logging_config import LoggingConfig # noqa: E402
|
||||
|
||||
# We import ray.actor because some code is run in actor.py which initializes
|
||||
# some functions in the worker.
|
||||
import ray.actor # noqa: E402,F401
|
||||
from ray.actor import method # noqa: E402,F401
|
||||
|
||||
# TODO(qwang): We should remove this exporting in Ray2.0.
|
||||
from ray.cross_language import java_function, java_actor_class # noqa: E402,F401
|
||||
from ray.runtime_context import get_runtime_context # noqa: E402,F401
|
||||
from ray import internal # noqa: E402,F401
|
||||
from ray import util # noqa: E402,F401
|
||||
from ray import _private # noqa: E402,F401
|
||||
|
||||
# We import ClientBuilder so that modules can inherit from `ray.ClientBuilder`.
|
||||
from ray.client_builder import client, ClientBuilder # noqa: E402,F401
|
||||
|
||||
|
||||
class _DeprecationWrapper:
|
||||
def __init__(self, name, real_worker):
|
||||
self._name = name
|
||||
self._real_worker = real_worker
|
||||
self._warned = set()
|
||||
|
||||
def __getattr__(self, attr):
|
||||
value = getattr(self._real_worker, attr)
|
||||
if attr not in self._warned:
|
||||
self._warned.add(attr)
|
||||
logger.warning(
|
||||
f"DeprecationWarning: `ray.{self._name}.{attr}` is a private "
|
||||
"attribute and access will be removed in a future Ray version."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# TODO(ekl) remove this entirely after 3rd party libraries are all migrated.
|
||||
worker = _DeprecationWrapper("worker", ray._private.worker)
|
||||
ray_constants = _DeprecationWrapper("ray_constants", ray._private.ray_constants)
|
||||
serialization = _DeprecationWrapper("serialization", ray._private.serialization)
|
||||
state = _DeprecationWrapper("state", ray._private.state)
|
||||
|
||||
|
||||
# Pulic Ray APIs
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"_config",
|
||||
"get_runtime_context",
|
||||
"autoscaler",
|
||||
"available_resources",
|
||||
"cancel",
|
||||
"client",
|
||||
"ClientBuilder",
|
||||
"cluster_resources",
|
||||
"get",
|
||||
"get_actor",
|
||||
"get_gpu_ids",
|
||||
"init",
|
||||
"is_initialized",
|
||||
"java_actor_class",
|
||||
"java_function",
|
||||
"cpp_function",
|
||||
"kill",
|
||||
"Language",
|
||||
"method",
|
||||
"nodes",
|
||||
"put",
|
||||
"remote",
|
||||
"shutdown",
|
||||
"timeline",
|
||||
"wait",
|
||||
"SCRIPT_MODE",
|
||||
"WORKER_MODE",
|
||||
"LoggingConfig",
|
||||
]
|
||||
|
||||
# Public APIs that should automatically trigger ray.init().
|
||||
AUTO_INIT_APIS = {
|
||||
"cancel",
|
||||
"get",
|
||||
"get_actor",
|
||||
"get_gpu_ids",
|
||||
"kill",
|
||||
"put",
|
||||
"wait",
|
||||
"get_runtime_context",
|
||||
}
|
||||
|
||||
# Public APIs that should not automatically trigger ray.init().
|
||||
NON_AUTO_INIT_APIS = {
|
||||
"ClientBuilder",
|
||||
"Language",
|
||||
"SCRIPT_MODE",
|
||||
"WORKER_MODE",
|
||||
"__version__",
|
||||
"_config",
|
||||
"autoscaler",
|
||||
"available_resources",
|
||||
"client",
|
||||
"cluster_resources",
|
||||
"cpp_function",
|
||||
"init",
|
||||
"is_initialized",
|
||||
"java_actor_class",
|
||||
"java_function",
|
||||
"method",
|
||||
"nodes",
|
||||
"remote",
|
||||
"shutdown",
|
||||
"timeline",
|
||||
"LoggingConfig",
|
||||
}
|
||||
|
||||
assert set(__all__) == AUTO_INIT_APIS | NON_AUTO_INIT_APIS
|
||||
from ray._private.auto_init_hook import wrap_auto_init_for_all_apis # noqa: E402
|
||||
|
||||
wrap_auto_init_for_all_apis(AUTO_INIT_APIS)
|
||||
del wrap_auto_init_for_all_apis
|
||||
|
||||
# Subpackages
|
||||
__all__ += [
|
||||
"actor",
|
||||
"autoscaler",
|
||||
"data",
|
||||
"internal",
|
||||
"util",
|
||||
"widgets",
|
||||
"workflow",
|
||||
]
|
||||
|
||||
# ID types
|
||||
__all__ += [
|
||||
"ActorClassID",
|
||||
"ActorID",
|
||||
"NodeID",
|
||||
"JobID",
|
||||
"WorkerID",
|
||||
"FunctionID",
|
||||
"ObjectID",
|
||||
"ObjectRef",
|
||||
"ObjectRefGenerator",
|
||||
"DynamicObjectRefGenerator",
|
||||
"TaskID",
|
||||
"UniqueID",
|
||||
"PlacementGroupID",
|
||||
]
|
||||
|
||||
|
||||
# Delay importing of expensive, isolated subpackages. Note that for proper type
|
||||
# checking support these imports must be kept in sync between type checking and
|
||||
# runtime behavior.
|
||||
if TYPE_CHECKING:
|
||||
from ray import autoscaler
|
||||
from ray import data
|
||||
from ray import workflow
|
||||
else:
|
||||
|
||||
def __getattr__(name: str):
|
||||
import importlib
|
||||
|
||||
if name in ["data", "workflow", "autoscaler"]:
|
||||
return importlib.import_module("." + name, __name__)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
del os
|
||||
del logging
|
||||
del sys
|
||||
del TYPE_CHECKING
|
||||
@@ -0,0 +1,7 @@
|
||||
# Common Utilities Shared Across the Libraries
|
||||
|
||||
This directory contains logic shared across Ray Core and the native libraries.
|
||||
|
||||
- All dependencies by the libraries on non-public APIs in the repo should live here. Libraries should _not_ depend on `ray._private`.
|
||||
- Interfaces exposed in this directory should be treated similarly to a "developer API."
|
||||
- End users and external libraries not inside the Ray repo should not depend on any code in `ray._common` (the same as `ray._private`).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Prefix for the node id resource that is automatically added to each node.
|
||||
# For example, a node may have id `node:172.23.42.1`.
|
||||
NODE_ID_PREFIX = "node:"
|
||||
# The system resource that head node has.
|
||||
HEAD_NODE_RESOURCE_NAME = NODE_ID_PREFIX + "__internal_head__"
|
||||
|
||||
RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_ENV_VAR = "RAY_WARN_BLOCKING_GET_INSIDE_ASYNC"
|
||||
@@ -0,0 +1,181 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, TypeVar, Union, cast, overload
|
||||
|
||||
from ray.util import log_once
|
||||
from ray.util.annotations import _mark_annotated
|
||||
|
||||
# TypeVar for preserving function/class signatures through decorators.
|
||||
# Note: These decorators also accept properties, but we use Callable for the
|
||||
# common case. Properties work at runtime but won't get full type inference.
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A constant to use for any configuration that should be deprecated
|
||||
# (to check, whether this config has actually been assigned a proper value or
|
||||
# not).
|
||||
DEPRECATED_VALUE = -1
|
||||
|
||||
|
||||
def deprecation_warning(
|
||||
old: str,
|
||||
new: Optional[str] = None,
|
||||
*,
|
||||
help: Optional[str] = None,
|
||||
error: Optional[Union[bool, type[Exception]]] = None,
|
||||
stacklevel: int = 2,
|
||||
) -> None:
|
||||
"""Warns (via the `logger` object) or throws a deprecation warning/error.
|
||||
|
||||
Args:
|
||||
old: A description of the "thing" that is to be deprecated.
|
||||
new: A description of the new "thing" that replaces it.
|
||||
help: An optional help text to tell the user, what to
|
||||
do instead of using `old`.
|
||||
error: Whether or which exception to raise. If True, raise ValueError.
|
||||
If False, just warn. If `error` is-a subclass of Exception,
|
||||
raise that Exception.
|
||||
stacklevel: The stacklevel to use for the warning message.
|
||||
Use 2 to point to where this function is called, 3+ to point
|
||||
further up the stack.
|
||||
|
||||
Raises:
|
||||
ValueError: If `error=True`.
|
||||
Exception: Of type `error`, iff `error` is a sub-class of `Exception`.
|
||||
"""
|
||||
msg = "`{}` has been deprecated.{}".format(
|
||||
old, (" Use `{}` instead.".format(new) if new else f" {help}" if help else "")
|
||||
)
|
||||
|
||||
if error:
|
||||
if not isinstance(error, bool) and issubclass(error, Exception):
|
||||
# error is an Exception
|
||||
raise error(msg)
|
||||
else:
|
||||
# error is a boolean, construct ValueError ourselves
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
logger.warning(
|
||||
"DeprecationWarning: " + msg + " This will raise an error in the future!",
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
|
||||
@overload
|
||||
def Deprecated(
|
||||
old: None = None,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def Deprecated(
|
||||
old: str,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
...
|
||||
|
||||
|
||||
def Deprecated(
|
||||
old: Optional[str] = None,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
"""Decorator for documenting a deprecated class, method, or function.
|
||||
|
||||
Automatically adds a `deprecation.deprecation_warning(old=...,
|
||||
error=False)` to not break existing code at this point to the decorated
|
||||
class' constructor, method, or function.
|
||||
|
||||
In a next major release, this warning should then be made an error
|
||||
(by setting error=True), which means at this point that the
|
||||
class/method/function is no longer supported, but will still inform
|
||||
the user about the deprecation event.
|
||||
|
||||
In a further major release, the class, method, function should be erased
|
||||
entirely from the codebase.
|
||||
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
from ray._common.deprecation import Deprecated
|
||||
# Deprecated class: Patches the constructor to warn if the class is
|
||||
# used.
|
||||
@Deprecated(new="NewAndMuchCoolerClass", error=False)
|
||||
class OldAndUncoolClass:
|
||||
...
|
||||
|
||||
# Deprecated class method: Patches the method to warn if called.
|
||||
class StillCoolClass:
|
||||
...
|
||||
@Deprecated(new="StillCoolClass.new_and_much_cooler_method()",
|
||||
error=False)
|
||||
def old_and_uncool_method(self, uncool_arg):
|
||||
...
|
||||
|
||||
# Deprecated function: Patches the function to warn if called.
|
||||
@Deprecated(new="new_and_much_cooler_function", error=False)
|
||||
def old_and_uncool_function(*uncool_args):
|
||||
...
|
||||
"""
|
||||
|
||||
def _inner(obj: F) -> F:
|
||||
# A deprecated class.
|
||||
if inspect.isclass(obj):
|
||||
# Patch the class' init method to raise the warning/error.
|
||||
obj_init = obj.__init__
|
||||
|
||||
def patched_init(*args, **kwargs):
|
||||
if log_once(old or obj.__name__):
|
||||
deprecation_warning(
|
||||
old=old or obj.__name__,
|
||||
new=new,
|
||||
help=help,
|
||||
error=error,
|
||||
stacklevel=3,
|
||||
)
|
||||
return obj_init(*args, **kwargs)
|
||||
|
||||
obj.__init__ = patched_init
|
||||
_mark_annotated(obj)
|
||||
# Return the patched class (with the warning/error when
|
||||
# instantiated).
|
||||
return obj
|
||||
|
||||
# A deprecated class method or function.
|
||||
# Patch with the warning/error at the beginning.
|
||||
def _ctor(*args, **kwargs):
|
||||
if log_once(old or obj.__name__):
|
||||
deprecation_warning(
|
||||
old=old or obj.__name__,
|
||||
new=new,
|
||||
help=help,
|
||||
error=error,
|
||||
stacklevel=3,
|
||||
)
|
||||
# Call the deprecated method/function.
|
||||
return obj(*args, **kwargs)
|
||||
|
||||
# Only apply @wraps for actual callables, not properties/descriptors.
|
||||
# Setting __wrapped__ on a property causes inspect.unwrap() to return
|
||||
# the property, which breaks inspect.signature() in the tracing helper.
|
||||
if callable(obj):
|
||||
_ctor = wraps(obj)(_ctor)
|
||||
|
||||
# Return the patched class method/function.
|
||||
return cast(F, _ctor)
|
||||
|
||||
# Return the prepared decorator.
|
||||
return _inner
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import ray
|
||||
from ray._common.logging_constants import LogKey
|
||||
|
||||
|
||||
class CoreContextFilter(logging.Filter):
|
||||
TASK_LEVEL_LOG_KEYS = [
|
||||
LogKey.TASK_ID.value,
|
||||
LogKey.TASK_NAME.value,
|
||||
LogKey.TASK_FUNCTION_NAME.value,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_ray_core_logging_context(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the ray core logging context as a dict.
|
||||
Only use this function if you need include the attributes to the log record
|
||||
yourself by bypassing the filter.
|
||||
"""
|
||||
if not ray.is_initialized():
|
||||
# There is no additional context if ray is not initialized
|
||||
return {}
|
||||
|
||||
runtime_context = ray.get_runtime_context()
|
||||
ray_core_logging_context = {
|
||||
LogKey.JOB_ID.value: runtime_context.get_job_id(),
|
||||
LogKey.WORKER_ID.value: runtime_context.get_worker_id(),
|
||||
LogKey.NODE_ID.value: runtime_context.get_node_id(),
|
||||
}
|
||||
if runtime_context.worker.mode == ray.WORKER_MODE:
|
||||
ray_core_logging_context[
|
||||
LogKey.ACTOR_ID.value
|
||||
] = runtime_context.get_actor_id()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_ID.value
|
||||
] = runtime_context.get_task_id()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_NAME.value
|
||||
] = runtime_context.get_task_name()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_FUNCTION_NAME.value
|
||||
] = runtime_context.get_task_function_name()
|
||||
ray_core_logging_context[
|
||||
LogKey.ACTOR_NAME.value
|
||||
] = runtime_context.get_actor_name()
|
||||
return ray_core_logging_context
|
||||
|
||||
def filter(self, record):
|
||||
context = self.get_ray_core_logging_context()
|
||||
for key, value in context.items():
|
||||
if value is not None and value != "":
|
||||
setattr(record, key, value)
|
||||
return True
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ray._common.logging_constants import (
|
||||
LOGGER_FLATTEN_KEYS,
|
||||
LOGRECORD_STANDARD_ATTRS,
|
||||
LogKey,
|
||||
)
|
||||
from ray._private.log import INTERNAL_TIMESTAMP_LOG_KEY
|
||||
from ray._private.ray_constants import LOGGER_FORMAT
|
||||
|
||||
|
||||
def _append_flatten_attributes(formatted_attrs: Dict[str, Any], key: str, value: Any):
|
||||
"""Flatten the dictionary values for special keys and append the values in place.
|
||||
|
||||
If the key is in `LOGGER_FLATTEN_KEYS`, the value will be flattened and appended
|
||||
to the `formatted_attrs` dictionary. Otherwise, the key-value pair will be appended
|
||||
directly.
|
||||
"""
|
||||
if key in LOGGER_FLATTEN_KEYS:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(
|
||||
f"Expected a dictionary passing into {key}, but got {type(value)}"
|
||||
)
|
||||
for k, v in value.items():
|
||||
if k in formatted_attrs:
|
||||
raise KeyError(f"Found duplicated key in the log record: {k}")
|
||||
formatted_attrs[k] = v
|
||||
else:
|
||||
formatted_attrs[key] = value
|
||||
|
||||
|
||||
class AbstractFormatter(logging.Formatter, ABC):
|
||||
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
|
||||
super().__init__(fmt, datefmt, style, validate)
|
||||
self._additional_log_standard_attrs = []
|
||||
|
||||
def set_additional_log_standard_attrs(
|
||||
self, additional_log_standard_attrs: List[str]
|
||||
) -> None:
|
||||
self._additional_log_standard_attrs = additional_log_standard_attrs
|
||||
|
||||
@property
|
||||
def additional_log_standard_attrs(self) -> List[str]:
|
||||
return self._additional_log_standard_attrs
|
||||
|
||||
def generate_record_format_attrs(
|
||||
self,
|
||||
record: logging.LogRecord,
|
||||
exclude_default_standard_attrs,
|
||||
) -> dict:
|
||||
record_format_attrs = {}
|
||||
|
||||
# If `exclude_default_standard_attrs` is False, include the standard attributes.
|
||||
# Otherwise, include only Ray and user-provided context.
|
||||
if not exclude_default_standard_attrs:
|
||||
record_format_attrs.update(
|
||||
{
|
||||
LogKey.ASCTIME.value: self.formatTime(record),
|
||||
LogKey.LEVELNAME.value: record.levelname,
|
||||
LogKey.MESSAGE.value: record.getMessage(),
|
||||
LogKey.FILENAME.value: record.filename,
|
||||
LogKey.LINENO.value: record.lineno,
|
||||
LogKey.PROCESS.value: record.process,
|
||||
}
|
||||
)
|
||||
if record.exc_info:
|
||||
if not record.exc_text:
|
||||
record.exc_text = self.formatException(record.exc_info)
|
||||
record_format_attrs[LogKey.EXC_TEXT.value] = record.exc_text
|
||||
|
||||
# Add the user specified additional standard attributes.
|
||||
for key in self._additional_log_standard_attrs:
|
||||
_append_flatten_attributes(
|
||||
record_format_attrs, key, getattr(record, key, None)
|
||||
)
|
||||
|
||||
for key, value in record.__dict__.items():
|
||||
# Both Ray and user-provided context are stored in `record_format`.
|
||||
if key not in LOGRECORD_STANDARD_ATTRS:
|
||||
_append_flatten_attributes(record_format_attrs, key, value)
|
||||
|
||||
# Format the internal timestamp to the standardized `timestamp_ns` key.
|
||||
if INTERNAL_TIMESTAMP_LOG_KEY in record_format_attrs:
|
||||
record_format_attrs[LogKey.TIMESTAMP_NS.value] = record_format_attrs.pop(
|
||||
INTERNAL_TIMESTAMP_LOG_KEY
|
||||
)
|
||||
|
||||
return record_format_attrs
|
||||
|
||||
@abstractmethod
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class JSONFormatter(AbstractFormatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
record_format_attrs = self.generate_record_format_attrs(
|
||||
record, exclude_default_standard_attrs=False
|
||||
)
|
||||
return json.dumps(record_format_attrs)
|
||||
|
||||
|
||||
class TextFormatter(AbstractFormatter):
|
||||
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
|
||||
super().__init__(fmt, datefmt, style, validate)
|
||||
self._inner_formatter = logging.Formatter(LOGGER_FORMAT)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
s = self._inner_formatter.format(record)
|
||||
record_format_attrs = self.generate_record_format_attrs(
|
||||
record, exclude_default_standard_attrs=True
|
||||
)
|
||||
|
||||
additional_attrs = " ".join(
|
||||
[f"{key}={value}" for key, value in record_format_attrs.items()]
|
||||
)
|
||||
return f"{s} {additional_attrs}"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Logging-related constants shared across Ray libraries.
|
||||
|
||||
Used to distinguish standard Python logging attributes from Ray or user context.
|
||||
See: https://docs.python.org/3/library/logging.html#logrecord-attributes
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
LOGRECORD_STANDARD_ATTRS = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
|
||||
LOGGER_FLATTEN_KEYS = {
|
||||
"ray_serve_extra_fields",
|
||||
}
|
||||
|
||||
|
||||
class LogKey(str, Enum):
|
||||
# Core context
|
||||
JOB_ID = "job_id"
|
||||
WORKER_ID = "worker_id"
|
||||
NODE_ID = "node_id"
|
||||
ACTOR_ID = "actor_id"
|
||||
TASK_ID = "task_id"
|
||||
ACTOR_NAME = "actor_name"
|
||||
TASK_NAME = "task_name"
|
||||
TASK_FUNCTION_NAME = "task_func_name"
|
||||
|
||||
# Logger built-in context
|
||||
ASCTIME = "asctime"
|
||||
LEVELNAME = "levelname"
|
||||
MESSAGE = "message"
|
||||
FILENAME = "filename"
|
||||
LINENO = "lineno"
|
||||
EXC_TEXT = "exc_text"
|
||||
PROCESS = "process"
|
||||
|
||||
# Ray logging context
|
||||
TIMESTAMP_NS = "timestamp_ns"
|
||||
@@ -0,0 +1,27 @@
|
||||
import socket
|
||||
from contextlib import closing
|
||||
|
||||
from ray._raylet import ( # noqa: F401
|
||||
build_address,
|
||||
get_all_interfaces_ip,
|
||||
get_localhost_ip,
|
||||
is_ipv6,
|
||||
is_localhost,
|
||||
node_ip_address_from_perspective,
|
||||
parse_address,
|
||||
)
|
||||
|
||||
|
||||
def find_free_port(family: socket.AddressFamily = socket.AF_INET) -> int:
|
||||
"""Find a free port on the local machine.
|
||||
|
||||
Args:
|
||||
family: The socket address family (AF_INET for IPv4, AF_INET6 for IPv6).
|
||||
Defaults to AF_INET.
|
||||
|
||||
Returns:
|
||||
An available port number.
|
||||
"""
|
||||
with closing(socket.socket(family, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""This module provides the Python API for emitting internal Ray events
|
||||
via the ONE-Event framework. Events are buffered and exported through
|
||||
the C++ RayEventRecorder.
|
||||
"""
|
||||
|
||||
from ray._common.observability.internal_event import InternalEventBuilder
|
||||
from ray._common.observability.platform_events import PlatformEventBuilder
|
||||
|
||||
__all__ = [
|
||||
"InternalEventBuilder",
|
||||
"PlatformEventBuilder",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Base class for building internal Ray events.
|
||||
|
||||
This module provides the base class for creating event builders that can
|
||||
emit events to dashboard-agents aggregator agent service.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from ray._raylet import RayEvent
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class InternalEventBuilder(ABC):
|
||||
"""Abstract base class for building internal Ray events.
|
||||
|
||||
Subclasses implement specific event types
|
||||
and must implement get_entity_id() and serialize_event_data().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: int,
|
||||
event_type: int,
|
||||
nested_event_field_number: int,
|
||||
severity: int = RayEventProto.Severity.INFO,
|
||||
message: str = "",
|
||||
session_name: str = "",
|
||||
):
|
||||
"""Initialize the event builder.
|
||||
|
||||
Args:
|
||||
source_type: RayEvent.SourceType enum value. Use
|
||||
RayEventProto.SourceType.<NAME> constants (e.g.,
|
||||
RayEventProto.SourceType.JOBS,
|
||||
RayEventProto.SourceType.GCS).
|
||||
event_type: RayEvent.EventType enum value. Use
|
||||
RayEventProto.EventType.<NAME> constants (e.g.,
|
||||
RayEventProto.EventType.SUBMISSION_JOB_DEFINITION_EVENT,
|
||||
RayEventProto.EventType.DRIVER_JOB_LIFECYCLE_EVENT).
|
||||
nested_event_field_number: The field number in RayEvent proto for the
|
||||
nested event message. Use RayEventProto.<FIELD>_FIELD_NUMBER
|
||||
constants (e.g.,
|
||||
RayEventProto.SUBMISSION_JOB_DEFINITION_EVENT_FIELD_NUMBER).
|
||||
severity: RayEvent.Severity enum value (default INFO).
|
||||
message: Optional message associated with the event.
|
||||
session_name: The Ray session name.
|
||||
"""
|
||||
self._source_type = source_type
|
||||
self._event_type = event_type
|
||||
self._nested_event_field_number = nested_event_field_number
|
||||
self._severity = severity
|
||||
self._message = message
|
||||
self._session_name = session_name
|
||||
|
||||
@abstractmethod
|
||||
def get_entity_id(self) -> str:
|
||||
"""Return the unique entity ID for this event.
|
||||
|
||||
The entity ID is used to associate related events (e.g., definition
|
||||
and lifecycle events for the same job).
|
||||
|
||||
Returns:
|
||||
A string identifier unique to this entity (e.g., node_id/task_id/actor_id/job_id).
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def serialize_event_data(self) -> bytes:
|
||||
"""Serialize the event-specific protobuf data to bytes.
|
||||
|
||||
Returns:
|
||||
Serialized protobuf bytes of the nested event message
|
||||
(e.g., SubmissionJobDefinitionEvent.SerializeToString()).
|
||||
"""
|
||||
pass
|
||||
|
||||
def build(
|
||||
self,
|
||||
event_id: Optional[bytes] = None,
|
||||
timestamp_ns: Optional[int] = None,
|
||||
) -> RayEvent:
|
||||
"""Build the Cython RayEvent object for submission.
|
||||
|
||||
Args:
|
||||
event_id: Optional explicit event id bytes. When omitted, the C++ layer
|
||||
generates a random id (matching the convention used by the other
|
||||
RayEventInterface subclasses). Provide an explicit value to reuse
|
||||
an id from an upstream source (e.g., a Kubernetes event uid).
|
||||
timestamp_ns: Optional explicit event timestamp in nanoseconds since the
|
||||
unix epoch. When omitted, the C++ layer captures the current time
|
||||
at construction. Provide an explicit value for platform events that
|
||||
carry their own source timestamp.
|
||||
|
||||
Returns:
|
||||
A RayEvent object.
|
||||
"""
|
||||
return RayEvent(
|
||||
source_type=self._source_type,
|
||||
event_type=self._event_type,
|
||||
severity=self._severity,
|
||||
entity_id=self.get_entity_id(),
|
||||
message=self._message,
|
||||
session_name=self._session_name,
|
||||
serialized_data=self.serialize_event_data(),
|
||||
nested_event_field_number=self._nested_event_field_number,
|
||||
event_id=event_id if event_id is not None else b"",
|
||||
timestamp_ns=int(timestamp_ns) if timestamp_ns is not None else 0,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray._common.observability.internal_event import InternalEventBuilder
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
|
||||
from ray.core.generated.platform_event_pb2 import PlatformEvent, Source
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PlatformEventBuilder(InternalEventBuilder):
|
||||
"""Builder for creating infrastructure PlatformEvents (e.g., from Kubernetes)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_uid: str,
|
||||
platform: int = Source.Platform.PLATFORM_UNSPECIFIED, # Source.Platform enum
|
||||
object_kind: str = "",
|
||||
object_name: str = "",
|
||||
reason: str = "",
|
||||
message: str = "",
|
||||
severity: int = RayEventProto.Severity.INFO,
|
||||
component: str = "",
|
||||
source_metadata: Optional[Dict[str, str]] = None,
|
||||
custom_fields: Optional[Dict[str, str]] = None,
|
||||
session_name: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
source_type=RayEventProto.SourceType.CLUSTER_LIFECYCLE,
|
||||
event_type=RayEventProto.EventType.PLATFORM_EVENT,
|
||||
nested_event_field_number=RayEventProto.PLATFORM_EVENT_FIELD_NUMBER,
|
||||
severity=severity,
|
||||
message=message,
|
||||
session_name=session_name,
|
||||
)
|
||||
self._event_uid = event_uid
|
||||
self._platform = platform
|
||||
self._object_kind = object_kind
|
||||
self._object_name = object_name
|
||||
self._reason = reason
|
||||
self._component = component
|
||||
self._source_metadata = source_metadata
|
||||
self._custom_fields = custom_fields
|
||||
|
||||
def get_entity_id(self) -> str:
|
||||
return self._event_uid
|
||||
|
||||
def serialize_event_data(self) -> bytes:
|
||||
source_proto = Source(
|
||||
platform=self._platform,
|
||||
component=self._component,
|
||||
)
|
||||
if self._source_metadata:
|
||||
for k, v in self._source_metadata.items():
|
||||
source_proto.metadata[k] = v
|
||||
|
||||
event = PlatformEvent(
|
||||
source=source_proto,
|
||||
object_kind=self._object_kind,
|
||||
object_name=self._object_name,
|
||||
message=self._message,
|
||||
reason=self._reason,
|
||||
)
|
||||
if self._custom_fields:
|
||||
for k, v in self._custom_fields.items():
|
||||
event.custom_fields[k] = v
|
||||
|
||||
return event.SerializeToString()
|
||||
@@ -0,0 +1,113 @@
|
||||
# ruff: noqa
|
||||
import packaging.version
|
||||
|
||||
# Pydantic is a dependency of `ray["default"]` but not the minimal installation,
|
||||
# so handle the case where it isn't installed.
|
||||
try:
|
||||
import pydantic
|
||||
|
||||
PYDANTIC_INSTALLED = True
|
||||
except ImportError:
|
||||
pydantic = None
|
||||
PYDANTIC_INSTALLED = False
|
||||
|
||||
|
||||
if not PYDANTIC_INSTALLED:
|
||||
IS_PYDANTIC_2 = False
|
||||
BaseModel = None
|
||||
Extra = None
|
||||
Field = None
|
||||
NonNegativeFloat = None
|
||||
NonNegativeInt = None
|
||||
PositiveFloat = None
|
||||
PositiveInt = None
|
||||
PrivateAttr = None
|
||||
StrictInt = None
|
||||
ValidationError = None
|
||||
root_validator = None
|
||||
validator = None
|
||||
|
||||
def is_subclass_of_base_model(obj):
|
||||
return False
|
||||
|
||||
elif not hasattr(pydantic, "__version__") or packaging.version.parse(
|
||||
pydantic.__version__
|
||||
) < packaging.version.parse("2.0"):
|
||||
raise ImportError(
|
||||
"Pydantic v1 is no longer supported in Ray. " "Please upgrade to `pydantic>=2`."
|
||||
)
|
||||
else:
|
||||
IS_PYDANTIC_2 = True
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Extra,
|
||||
Field,
|
||||
NonNegativeFloat,
|
||||
NonNegativeInt,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
PrivateAttr,
|
||||
StrictInt,
|
||||
ValidationError,
|
||||
root_validator,
|
||||
validator,
|
||||
)
|
||||
|
||||
def is_subclass_of_base_model(obj):
|
||||
return issubclass(obj, BaseModel)
|
||||
|
||||
|
||||
def _iter_model_field_types():
|
||||
model_field_types = []
|
||||
|
||||
try:
|
||||
from pydantic.fields import ModelField as model_field_type
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
model_field_types.append(model_field_type)
|
||||
|
||||
try:
|
||||
from pydantic.v1.fields import ModelField as compat_model_field_type
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
if compat_model_field_type not in model_field_types:
|
||||
model_field_types.append(compat_model_field_type)
|
||||
|
||||
return model_field_types
|
||||
|
||||
|
||||
def register_pydantic_serializers(serialization_context):
|
||||
if not PYDANTIC_INSTALLED:
|
||||
return
|
||||
|
||||
# Pydantic's Cython validators are not serializable.
|
||||
# https://github.com/cloudpipe/cloudpickle/issues/408
|
||||
#
|
||||
# FastAPI can still surface Pydantic's v1 compatibility ModelField under
|
||||
# Pydantic v2, so we need to register serializers for both types until that
|
||||
# compatibility path is no longer used upstream.
|
||||
for model_field_type in _iter_model_field_types():
|
||||
serialization_context._register_cloudpickle_serializer(
|
||||
model_field_type,
|
||||
custom_serializer=lambda o: {
|
||||
"name": o.name,
|
||||
# outer_type_ is the original type for ModelFields,
|
||||
# while type_ can be updated later with the nested type
|
||||
# like int for List[int].
|
||||
"type_": o.outer_type_,
|
||||
"class_validators": o.class_validators,
|
||||
"model_config": o.model_config,
|
||||
"default": o.default,
|
||||
"default_factory": o.default_factory,
|
||||
"required": o.required,
|
||||
"alias": o.alias,
|
||||
"field_info": o.field_info,
|
||||
},
|
||||
custom_deserializer=(
|
||||
lambda kwargs, model_field_type=model_field_type: model_field_type(
|
||||
**kwargs
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default max_concurrency option in @ray.remote for async actors.
|
||||
DEFAULT_MAX_CONCURRENCY_ASYNC = 1000
|
||||
|
||||
LOGGING_ROTATE_BYTES = 512 * 1024 * 1024 # 512MB.
|
||||
LOGGING_ROTATE_BACKUP_COUNT = 5 # 5 Backup files at max.
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Manage, parse and validate options for Ray tasks, actors and actor methods."""
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
import ray
|
||||
from ray._private import ray_constants
|
||||
from ray._private.label_utils import (
|
||||
validate_fallback_strategy,
|
||||
validate_label_selector,
|
||||
)
|
||||
from ray._private.utils import get_ray_doc_version
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
from ray.util.scheduling_strategies import (
|
||||
NodeAffinitySchedulingStrategy,
|
||||
NodeLabelSchedulingStrategy,
|
||||
PlacementGroupSchedulingStrategy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Option:
|
||||
# Type constraint of an option.
|
||||
type_constraint: Optional[Union[type, Tuple[type]]] = None
|
||||
# Value constraint of an option.
|
||||
# The callable should return None if there is no error.
|
||||
# Otherwise, return the error message.
|
||||
value_constraint: Optional[Callable[[Any], Optional[str]]] = None
|
||||
# Default value.
|
||||
default_value: Any = None
|
||||
|
||||
def validate(self, keyword: str, value: Any):
|
||||
"""Validate the option."""
|
||||
if self.type_constraint is not None:
|
||||
if not isinstance(value, self.type_constraint):
|
||||
raise TypeError(
|
||||
f"The type of keyword '{keyword}' must be {self.type_constraint}, "
|
||||
f"but received type {type(value)}"
|
||||
)
|
||||
if self.value_constraint is not None:
|
||||
possible_error_message = self.value_constraint(value)
|
||||
if possible_error_message:
|
||||
raise ValueError(possible_error_message)
|
||||
|
||||
|
||||
def _counting_option(name: str, infinite: bool = True, default_value: Any = None):
|
||||
"""This is used for positive and discrete options.
|
||||
|
||||
Args:
|
||||
name: The name of the option keyword.
|
||||
infinite: If True, user could use -1 to represent infinity.
|
||||
default_value: The default value for this option.
|
||||
|
||||
Returns:
|
||||
An Option object.
|
||||
"""
|
||||
if infinite:
|
||||
return Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x >= -1)
|
||||
else f"The keyword '{name}' only accepts None, 0, -1"
|
||||
" or a positive integer, where -1 represents infinity.",
|
||||
default_value=default_value,
|
||||
)
|
||||
return Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x >= 0)
|
||||
else f"The keyword '{name}' only accepts None, 0 or a positive integer.",
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
|
||||
def _validate_resource_quantity(name, quantity):
|
||||
if quantity < 0:
|
||||
return f"The quantity of resource {name} cannot be negative"
|
||||
if (
|
||||
isinstance(quantity, float)
|
||||
and quantity != 0.0
|
||||
and int(quantity * ray._raylet.RESOURCE_UNIT_SCALING) == 0
|
||||
):
|
||||
return (
|
||||
f"The precision of the fractional quantity of resource {name}"
|
||||
" cannot go beyond 0.0001"
|
||||
)
|
||||
resource_name = "GPU" if name == "num_gpus" else name
|
||||
if resource_name in ray._private.accelerators.get_all_accelerator_resource_names():
|
||||
(
|
||||
valid,
|
||||
error_message,
|
||||
) = ray._private.accelerators.get_accelerator_manager_for_resource(
|
||||
resource_name
|
||||
).validate_resource_request_quantity(
|
||||
quantity
|
||||
)
|
||||
if not valid:
|
||||
return error_message
|
||||
return None
|
||||
|
||||
|
||||
def _resource_option(name: str, default_value: Any = None):
|
||||
"""This is used for resource related options."""
|
||||
return Option(
|
||||
(float, int, type(None)),
|
||||
lambda x: None if (x is None) else _validate_resource_quantity(name, x),
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
|
||||
def _validate_resources(resources: Optional[Dict[str, float]]) -> Optional[str]:
|
||||
if resources is None:
|
||||
return None
|
||||
|
||||
if "CPU" in resources or "GPU" in resources:
|
||||
return (
|
||||
"Use the 'num_cpus' and 'num_gpus' keyword instead of 'CPU' and 'GPU' "
|
||||
"in 'resources' keyword"
|
||||
)
|
||||
|
||||
for name, quantity in resources.items():
|
||||
possible_error_message = _validate_resource_quantity(name, quantity)
|
||||
if possible_error_message:
|
||||
return possible_error_message
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_common_options = {
|
||||
"label_selector": Option((dict, type(None)), lambda x: validate_label_selector(x)),
|
||||
"fallback_strategy": Option(
|
||||
(list, type(None)), lambda x: validate_fallback_strategy(x)
|
||||
),
|
||||
"accelerator_type": Option((str, type(None))),
|
||||
"memory": _resource_option("memory"),
|
||||
"name": Option((str, type(None))),
|
||||
"num_cpus": _resource_option("num_cpus"),
|
||||
"num_gpus": _resource_option("num_gpus"),
|
||||
"object_store_memory": _counting_option("object_store_memory", False),
|
||||
# TODO(suquark): "placement_group", "placement_group_bundle_index"
|
||||
# and "placement_group_capture_child_tasks" are deprecated,
|
||||
# use "scheduling_strategy" instead.
|
||||
"placement_group": Option(
|
||||
(type(None), str, PlacementGroup), default_value="default"
|
||||
),
|
||||
"placement_group_bundle_index": Option(int, default_value=-1),
|
||||
"placement_group_capture_child_tasks": Option((bool, type(None))),
|
||||
"resources": Option((dict, type(None)), lambda x: _validate_resources(x)),
|
||||
"runtime_env": Option((dict, type(None))),
|
||||
"scheduling_strategy": Option(
|
||||
(
|
||||
type(None),
|
||||
str,
|
||||
PlacementGroupSchedulingStrategy,
|
||||
NodeAffinitySchedulingStrategy,
|
||||
NodeLabelSchedulingStrategy,
|
||||
)
|
||||
),
|
||||
"enable_task_events": Option(bool, default_value=True),
|
||||
"_labels": Option((dict, type(None))),
|
||||
}
|
||||
|
||||
|
||||
def issubclass_safe(obj: Any, cls_: type) -> bool:
|
||||
try:
|
||||
return issubclass(obj, cls_)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
_task_only_options = {
|
||||
"max_calls": _counting_option("max_calls", False, default_value=0),
|
||||
# Normal tasks may be retried on failure this many times.
|
||||
# TODO(swang): Allow this to be set globally for an application.
|
||||
"max_retries": _counting_option(
|
||||
"max_retries", default_value=ray_constants.DEFAULT_TASK_MAX_RETRIES
|
||||
),
|
||||
# override "_common_options"
|
||||
"num_cpus": _resource_option("num_cpus", default_value=1),
|
||||
"num_returns": Option(
|
||||
(int, str, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x == "dynamic" or x == "streaming" or x >= 0)
|
||||
else "Default None. When None is passed, "
|
||||
"The default value is 1 for a task and actor task, and "
|
||||
"'streaming' for generator tasks and generator actor tasks. "
|
||||
"The keyword 'num_returns' only accepts None, "
|
||||
"a non-negative integer, "
|
||||
"'streaming' (for generators), or 'dynamic'. 'dynamic' flag "
|
||||
"will be deprecated in the future, and it is recommended to use "
|
||||
"'streaming' instead.",
|
||||
default_value=None,
|
||||
),
|
||||
"object_store_memory": Option( # override "_common_options"
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None)
|
||||
else "Setting 'object_store_memory' is not implemented for tasks",
|
||||
),
|
||||
"retry_exceptions": Option(
|
||||
(bool, list, tuple),
|
||||
lambda x: None
|
||||
if (
|
||||
isinstance(x, bool)
|
||||
or (
|
||||
isinstance(x, (list, tuple))
|
||||
and all(issubclass_safe(x_, Exception) for x_ in x)
|
||||
)
|
||||
)
|
||||
else "retry_exceptions must be either a boolean or a list of exceptions",
|
||||
default_value=False,
|
||||
),
|
||||
"_generator_backpressure_num_objects": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if x != 0
|
||||
else (
|
||||
"_generator_backpressure_num_objects=0 is not allowed. "
|
||||
"Use a value > 0. If the value is equal to 1, the behavior "
|
||||
"is identical to Python generator (generator 1 object "
|
||||
"whenever `next` is called). Use -1 to disable this feature. "
|
||||
),
|
||||
),
|
||||
"_num_objects_per_yield": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x > 0)
|
||||
else (
|
||||
"_num_objects_per_yield is a private streaming generator option "
|
||||
"that must be set to a positive integer."
|
||||
),
|
||||
default_value=1,
|
||||
),
|
||||
}
|
||||
|
||||
_actor_only_options = {
|
||||
"concurrency_groups": Option((list, dict, type(None))),
|
||||
"enable_tensor_transport": Option((bool, type(None)), default_value=None),
|
||||
"lifetime": Option(
|
||||
(str, type(None)),
|
||||
lambda x: None
|
||||
if x in (None, "detached", "non_detached")
|
||||
else "actor `lifetime` argument must be one of 'detached', "
|
||||
"'non_detached' and 'None'.",
|
||||
),
|
||||
"max_concurrency": _counting_option("max_concurrency", False),
|
||||
"max_restarts": _counting_option("max_restarts", default_value=0),
|
||||
"max_task_retries": _counting_option("max_task_retries", default_value=0),
|
||||
"max_pending_calls": _counting_option("max_pending_calls", default_value=-1),
|
||||
"namespace": Option((str, type(None))),
|
||||
"get_if_exists": Option(bool, default_value=False),
|
||||
"allow_out_of_order_execution": Option((bool, type(None))),
|
||||
# Actor-wide cap on the number of unconsumed streaming-generator
|
||||
# objects across all generator tasks running on the actor. Coexists
|
||||
# with the per-method `_generator_backpressure_num_objects`: both
|
||||
# apply, and the producer blocks on whichever is tighter. -1 (or
|
||||
# None / unset) disables the actor-wide cap.
|
||||
"_actor_generator_backpressure_num_objects": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x > 0 or x == -1)
|
||||
else (
|
||||
"_actor_generator_backpressure_num_objects must be > 0 to cap the "
|
||||
"actor's total unconsumed generator objects, or -1 to disable. "
|
||||
f"Got {x}."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
# Priority is important here because during dictionary update, same key with higher
|
||||
# priority overrides the same key with lower priority. We make use of priority
|
||||
# to set the correct default value for tasks / actors.
|
||||
|
||||
# priority: _common_options > _actor_only_options > _task_only_options
|
||||
valid_options: Dict[str, Option] = {
|
||||
**_task_only_options,
|
||||
**_actor_only_options,
|
||||
**_common_options,
|
||||
}
|
||||
# priority: _task_only_options > _common_options
|
||||
task_options: Dict[str, Option] = {**_common_options, **_task_only_options}
|
||||
# priority: _actor_only_options > _common_options
|
||||
actor_options: Dict[str, Option] = {**_common_options, **_actor_only_options}
|
||||
|
||||
remote_args_error_string = (
|
||||
"The @ray.remote decorator must be applied either with no arguments and no "
|
||||
"parentheses, for example '@ray.remote', or it must be applied using some of "
|
||||
f"the arguments in the list {list(valid_options.keys())}, for example "
|
||||
"'@ray.remote(num_returns=2, resources={\"CustomResource\": 1})'."
|
||||
)
|
||||
|
||||
|
||||
def _check_deprecate_placement_group(options: Dict[str, Any]):
|
||||
"""Check if deprecated placement group option exists."""
|
||||
placement_group = options.get("placement_group", "default")
|
||||
scheduling_strategy = options.get("scheduling_strategy")
|
||||
# TODO(suquark): @ray.remote(placement_group=None) is used in
|
||||
# "python/ray.data._internal/remote_fn.py" and many other places,
|
||||
# while "ray.data.read_api.read_datasource" set "scheduling_strategy=SPREAD".
|
||||
# This might be a bug, but it is also ok to allow them co-exist.
|
||||
if (placement_group not in ("default", None)) and (scheduling_strategy is not None):
|
||||
raise ValueError(
|
||||
"Placement groups should be specified via the "
|
||||
"scheduling_strategy option. "
|
||||
"The placement_group option is deprecated."
|
||||
)
|
||||
|
||||
|
||||
def _warn_if_using_deprecated_placement_group(
|
||||
options: Dict[str, Any], caller_stacklevel: int
|
||||
):
|
||||
placement_group = options["placement_group"]
|
||||
placement_group_bundle_index = options["placement_group_bundle_index"]
|
||||
placement_group_capture_child_tasks = options["placement_group_capture_child_tasks"]
|
||||
if placement_group != "default":
|
||||
warnings.warn(
|
||||
"placement_group parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
if placement_group_bundle_index != -1:
|
||||
warnings.warn(
|
||||
"placement_group_bundle_index parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
if placement_group_capture_child_tasks:
|
||||
warnings.warn(
|
||||
"placement_group_capture_child_tasks parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
|
||||
|
||||
def validate_task_options(
|
||||
options: Dict[str, Any],
|
||||
in_options: bool,
|
||||
is_generator_callable: Optional[bool] = None,
|
||||
):
|
||||
"""Options check for Ray tasks.
|
||||
|
||||
Args:
|
||||
options: Options for Ray tasks.
|
||||
in_options: If True, we are checking the options under the context of
|
||||
".options()".
|
||||
is_generator_callable: Optional bool indicating whether the callable is a
|
||||
generator function. If provided and num_returns is 'streaming' or
|
||||
'dynamic', validates that the callable is a generator.
|
||||
"""
|
||||
for k, v in options.items():
|
||||
if k not in task_options:
|
||||
raise ValueError(
|
||||
f"Invalid option keyword {k} for remote functions. "
|
||||
f"Valid ones are {list(task_options)}."
|
||||
)
|
||||
task_options[k].validate(k, v)
|
||||
if in_options and "max_calls" in options:
|
||||
raise ValueError("Setting 'max_calls' is not supported in '.options()'.")
|
||||
_check_deprecate_placement_group(options)
|
||||
|
||||
if is_generator_callable is not None:
|
||||
num_returns = options.get("num_returns")
|
||||
if num_returns is not None:
|
||||
validate_num_returns(is_generator_callable, num_returns)
|
||||
|
||||
|
||||
def validate_actor_options(options: Dict[str, Any], in_options: bool):
|
||||
"""Options check for Ray actors.
|
||||
|
||||
Args:
|
||||
options: Options for Ray actors.
|
||||
in_options: If True, we are checking the options under the context of
|
||||
".options()".
|
||||
"""
|
||||
for k, v in options.items():
|
||||
if k not in actor_options:
|
||||
raise ValueError(
|
||||
f"Invalid option keyword {k} for actors. "
|
||||
f"Valid ones are {list(actor_options)}."
|
||||
)
|
||||
actor_options[k].validate(k, v)
|
||||
|
||||
if in_options and "concurrency_groups" in options:
|
||||
raise ValueError(
|
||||
"Setting 'concurrency_groups' is not supported in '.options()'."
|
||||
)
|
||||
|
||||
if options.get("get_if_exists") and not options.get("name"):
|
||||
raise ValueError("The actor name must be specified to use `get_if_exists`.")
|
||||
|
||||
if "object_store_memory" in options:
|
||||
warnings.warn(
|
||||
"Setting 'object_store_memory'"
|
||||
" for actors is deprecated since it doesn't actually"
|
||||
" reserve the required object store memory."
|
||||
f" Use object spilling that's enabled by default (https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/objects/object-spilling.html) " # noqa: E501
|
||||
"instead to bypass the object store memory size limitation.",
|
||||
DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
_check_deprecate_placement_group(options)
|
||||
|
||||
|
||||
def validate_num_returns(is_generator_callable: bool, num_returns: Any) -> None:
|
||||
"""Validate num_returns for @ray.remote and @ray.method decorators.
|
||||
|
||||
This function validates:
|
||||
1. If num_returns is an integer < 0, it should fail fast.
|
||||
2. If num_returns='streaming' or 'dynamic' is used with a non-generator
|
||||
function, it should fail fast.
|
||||
|
||||
Args:
|
||||
is_generator_callable: Whether the callable is a generator function or
|
||||
async generator function.
|
||||
num_returns: The num_returns value to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If num_returns < 0, or if num_returns is 'streaming' or 'dynamic'
|
||||
but the callable is not a generator function or async generator function.
|
||||
"""
|
||||
if num_returns is None:
|
||||
return
|
||||
|
||||
# Validate num_returns < 0
|
||||
if isinstance(num_returns, int) and num_returns < 0:
|
||||
raise ValueError(f"num_returns must be >= 0, but got {num_returns}.")
|
||||
|
||||
# Validate num_returns='streaming' or 'dynamic' for generator functions
|
||||
if num_returns in ("streaming", "dynamic") and not is_generator_callable:
|
||||
raise ValueError(
|
||||
f"num_returns='{num_returns}' can only be used with generator functions "
|
||||
f"(functions that use 'yield'). "
|
||||
f"The decorated function is not a generator function."
|
||||
)
|
||||
|
||||
|
||||
def update_options(
|
||||
original_options: Dict[str, Any], new_options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Update original options with new options and return.
|
||||
The returned updated options contain shallow copy of original options.
|
||||
"""
|
||||
|
||||
return {**original_options, **new_options}
|
||||
@@ -0,0 +1,162 @@
|
||||
import functools
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Sequence
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
try:
|
||||
from typing import ParamSpec
|
||||
except ImportError:
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
R = TypeVar("R")
|
||||
P = ParamSpec("P")
|
||||
|
||||
|
||||
def format_exception(exc: BaseException, include_cause: bool = False) -> str:
|
||||
"""Format ``exc`` as ``"ClassName: message"`` for substring/regex matching.
|
||||
|
||||
Uses `traceback.format_exception_only` so the class name is preserved
|
||||
and callers can match on either the class name or the message.
|
||||
|
||||
Args:
|
||||
exc: The exception to format.
|
||||
include_cause: If True and ``exc.__cause__`` is set (``raise X from Y``),
|
||||
append the cause exception after the base exception. This is useful when
|
||||
we want to match on an exception encountered in the UDF (e.g. ``RateLimitError``)
|
||||
which is wrapped in a ``UserCodeException`` by Ray Data.
|
||||
|
||||
Returns:
|
||||
A single-string representation of ``exc`` in the form
|
||||
``"ClassName: message"``. When ``include_cause`` is True and
|
||||
``exc.__cause__`` is set, the cause's formatted form is appended
|
||||
after a single space. See the example below.
|
||||
|
||||
Example:
|
||||
For a ``UserCodeException`` wrapping a ``RateLimitError``, calling ``format_exception(e, include_cause=True)``
|
||||
returns::
|
||||
|
||||
ray.exceptions.UserCodeException: UDF failed to process a data block. RateLimitError: Error code: 429 - rate limited
|
||||
"""
|
||||
s = "".join(traceback.format_exception_only(type(exc), exc)).rstrip("\n")
|
||||
if include_cause and exc.__cause__:
|
||||
cause = exc.__cause__
|
||||
s += " " + "".join(traceback.format_exception_only(type(cause), cause)).rstrip(
|
||||
"\n"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def matches_error(pattern: str, error_str: str) -> bool:
|
||||
"""True if ``pattern`` matches ``error_str`` as a substring or as a regex.
|
||||
|
||||
Substring is tried first so literal patterns are not interpreted as regex.
|
||||
Invalid regex patterns return False instead of raising.
|
||||
|
||||
Args:
|
||||
pattern: Pattern to match, tried first as a substring then as a regex.
|
||||
error_str: Formatted exception string.
|
||||
|
||||
Returns:
|
||||
True if ``pattern`` matches ``error_str`` as a substring or as a regex.
|
||||
"""
|
||||
if pattern in error_str:
|
||||
return True
|
||||
try:
|
||||
return bool(re.search(pattern, error_str))
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
def call_with_retry(
|
||||
f: Callable[P, R],
|
||||
description: str,
|
||||
match: Optional[Sequence[str]] = None,
|
||||
max_attempts: int = 10,
|
||||
max_backoff_s: int = 32,
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> R:
|
||||
"""Retry a function with exponential backoff.
|
||||
|
||||
Args:
|
||||
f: The function to retry.
|
||||
description: An imperative description of the function being retried. For
|
||||
example, "open the file".
|
||||
match: A sequence of patterns to match in the exception message. Each
|
||||
pattern is first checked as a substring, then as a regex. If
|
||||
``None``, any error is retried.
|
||||
max_attempts: The maximum number of attempts to retry.
|
||||
max_backoff_s: The maximum number of seconds to backoff.
|
||||
*args: Arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function.
|
||||
"""
|
||||
# TODO: consider inverse match and matching exception type
|
||||
assert max_attempts >= 1, f"`max_attempts` must be positive. Got {max_attempts}."
|
||||
|
||||
for i in range(max_attempts):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
exception_str = format_exception(e)
|
||||
is_retryable = match is None or any(
|
||||
matches_error(pattern, exception_str) for pattern in match
|
||||
)
|
||||
if is_retryable and i + 1 < max_attempts:
|
||||
# Retry with binary exponential backoff with 20% random jitter.
|
||||
backoff = min(2**i, max_backoff_s) * (random.uniform(0.8, 1.2))
|
||||
logger.debug(
|
||||
f"Retrying {i+1} attempts to {description} after {backoff} seconds."
|
||||
)
|
||||
time.sleep(backoff)
|
||||
else:
|
||||
if is_retryable:
|
||||
logger.debug(
|
||||
f"Failed to {description} after {max_attempts} attempts. Raising."
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Did not find a match for {exception_str}. Raising after {i+1} attempts."
|
||||
)
|
||||
raise e from None
|
||||
|
||||
|
||||
def retry(
|
||||
description: str,
|
||||
match: Optional[Sequence[str]] = None,
|
||||
max_attempts: int = 10,
|
||||
max_backoff_s: int = 32,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Decorator-based version of call_with_retry.
|
||||
|
||||
Args:
|
||||
description: An imperative description of the function being retried. For
|
||||
example, "open the file".
|
||||
match: A sequence of patterns to match in the exception message. Each
|
||||
pattern is first checked as a substring, then as a regex. If
|
||||
``None``, any error is retried.
|
||||
max_attempts: The maximum number of attempts to retry.
|
||||
max_backoff_s: The maximum number of seconds to backoff.
|
||||
|
||||
Returns:
|
||||
A Callable that can be applied in a normal decorator fashion.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@functools.wraps(func)
|
||||
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return call_with_retry(
|
||||
func, description, match, max_attempts, max_backoff_s, *args, **kwargs
|
||||
)
|
||||
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,34 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
import ray._private.utils
|
||||
import ray.cloudpickle as pickle
|
||||
import ray.exceptions
|
||||
from ray._private import ray_constants
|
||||
from ray.util import inspect_serializability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ALLOW_OUT_OF_BAND_OBJECT_REF_SERIALIZATION = ray_constants.env_bool(
|
||||
"RAY_allow_out_of_band_object_ref_serialization", True
|
||||
)
|
||||
|
||||
|
||||
def pickle_dumps(obj: Any, error_msg: str):
|
||||
"""Wrap cloudpickle.dumps to provide better error message
|
||||
when the object is not serializable.
|
||||
"""
|
||||
try:
|
||||
return pickle.dumps(obj)
|
||||
except (TypeError, ray.exceptions.OufOfBandObjectRefSerializationException) as e:
|
||||
sio = io.StringIO()
|
||||
inspect_serializability(obj, print_file=sio)
|
||||
msg = f"{error_msg}:\n{sio.getvalue()}"
|
||||
if isinstance(e, TypeError):
|
||||
raise TypeError(msg) from e
|
||||
else:
|
||||
raise ray.exceptions.OufOfBandObjectRefSerializationException(msg)
|
||||
@@ -0,0 +1,163 @@
|
||||
import inspect
|
||||
import logging
|
||||
from inspect import Parameter
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from ray._private.inspect_util import is_cython
|
||||
|
||||
# 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__)
|
||||
|
||||
# This dummy type is also defined in ArgumentsBuilder.java. Please keep it
|
||||
# synced.
|
||||
DUMMY_TYPE = b"__RAY_DUMMY__"
|
||||
|
||||
|
||||
def get_signature(func: Any) -> inspect.Signature:
|
||||
"""Get signature parameters.
|
||||
|
||||
Support Cython functions by grabbing relevant attributes from the Cython
|
||||
function and attaching to a no-op function. This is somewhat brittle, since
|
||||
inspect may change, but given that inspect is written to a PEP, we hope
|
||||
it is relatively stable. Future versions of Python may allow overloading
|
||||
the inspect 'isfunction' and 'ismethod' functions / create ABC for Python
|
||||
functions. Until then, it appears that Cython won't do anything about
|
||||
compatability with the inspect module.
|
||||
|
||||
Args:
|
||||
func: The function whose signature should be checked.
|
||||
|
||||
Returns:
|
||||
A function signature object, which includes the names of the keyword
|
||||
arguments as well as their default values.
|
||||
|
||||
Raises:
|
||||
TypeError: A type error if the signature is not supported
|
||||
"""
|
||||
# The first condition for Cython functions, the latter for Cython instance
|
||||
# methods
|
||||
if is_cython(func):
|
||||
attrs = ["__code__", "__annotations__", "__defaults__", "__kwdefaults__"]
|
||||
|
||||
if all(hasattr(func, attr) for attr in attrs):
|
||||
original_func = func
|
||||
|
||||
def func():
|
||||
return
|
||||
|
||||
for attr in attrs:
|
||||
setattr(func, attr, getattr(original_func, attr))
|
||||
else:
|
||||
raise TypeError(f"{func!r} is not a Python function we can process")
|
||||
|
||||
return inspect.signature(func)
|
||||
|
||||
|
||||
def extract_signature(func: Any, ignore_first: bool = False) -> List[Parameter]:
|
||||
"""Extract the function signature from the function.
|
||||
|
||||
Args:
|
||||
func: The function whose signature should be extracted.
|
||||
ignore_first: True if the first argument should be ignored. This should
|
||||
be used when func is a method of a class.
|
||||
|
||||
Returns:
|
||||
List of Parameter objects representing the function signature.
|
||||
"""
|
||||
signature_parameters = list(get_signature(func).parameters.values())
|
||||
|
||||
if ignore_first:
|
||||
if len(signature_parameters) == 0:
|
||||
raise ValueError(
|
||||
"Methods must take a 'self' argument, but the "
|
||||
f"method '{func.__name__}' does not have one."
|
||||
)
|
||||
signature_parameters = signature_parameters[1:]
|
||||
|
||||
return signature_parameters
|
||||
|
||||
|
||||
def validate_args(
|
||||
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Validates the arguments against the signature.
|
||||
|
||||
Args:
|
||||
signature_parameters: The list of Parameter objects
|
||||
representing the function signature, obtained from
|
||||
`extract_signature`.
|
||||
args: The positional arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if arguments do not fit in the function signature.
|
||||
"""
|
||||
reconstructed_signature = inspect.Signature(parameters=signature_parameters)
|
||||
try:
|
||||
reconstructed_signature.bind(*args, **kwargs)
|
||||
except TypeError as exc: # capture a friendlier stacktrace
|
||||
raise TypeError(str(exc)) from None
|
||||
|
||||
|
||||
def flatten_args(
|
||||
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
|
||||
) -> List[Any]:
|
||||
"""Validates the arguments against the signature and flattens them.
|
||||
|
||||
The flat list representation is a serializable format for arguments.
|
||||
Since the flatbuffer representation of function arguments is a list, we
|
||||
combine both keyword arguments and positional arguments. We represent
|
||||
this with two entries per argument value - [DUMMY_TYPE, x] for positional
|
||||
arguments and [KEY, VALUE] for keyword arguments. See the below example.
|
||||
See `recover_args` for logic restoring the flat list back to args/kwargs.
|
||||
|
||||
Args:
|
||||
signature_parameters: The list of Parameter objects
|
||||
representing the function signature, obtained from
|
||||
`extract_signature`.
|
||||
args: The positional arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
|
||||
Returns:
|
||||
List of args and kwargs. Non-keyword arguments are prefixed
|
||||
by internal enum DUMMY_TYPE.
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if arguments do not fit in the function signature.
|
||||
"""
|
||||
validate_args(signature_parameters, args, kwargs)
|
||||
list_args = []
|
||||
for arg in args:
|
||||
list_args += [DUMMY_TYPE, arg]
|
||||
|
||||
for keyword, arg in kwargs.items():
|
||||
list_args += [keyword, arg]
|
||||
return list_args
|
||||
|
||||
|
||||
def recover_args(flattened_args: List[Any]) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
"""Recreates `args` and `kwargs` from the flattened arg list.
|
||||
|
||||
Args:
|
||||
flattened_args: List of args and kwargs. This should be the output of
|
||||
`flatten_args`.
|
||||
|
||||
Returns:
|
||||
args: The non-keyword arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
"""
|
||||
assert (
|
||||
len(flattened_args) % 2 == 0
|
||||
), "Flattened arguments need to be even-numbered. See `flatten_args`."
|
||||
args = []
|
||||
kwargs = {}
|
||||
for name_index in range(0, len(flattened_args), 2):
|
||||
name, arg = flattened_args[name_index], flattened_args[name_index + 1]
|
||||
if name == DUMMY_TYPE:
|
||||
args.append(arg)
|
||||
else:
|
||||
kwargs[name] = arg
|
||||
|
||||
return args, kwargs
|
||||
@@ -0,0 +1,530 @@
|
||||
"""Test utilities for Ray.
|
||||
|
||||
This module contains test utility classes that are distributed with the Ray package
|
||||
and can be used by external libraries and tests. These utilities must remain in
|
||||
_common/ (not in tests/) to be accessible in the Ray package distribution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Set
|
||||
|
||||
import ray
|
||||
import ray._common.usage.usage_lib as ray_usage_lib
|
||||
import ray._private.utils
|
||||
from ray._common.network_utils import build_address
|
||||
from ray._common.utils import decode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from prometheus_client.core import Metric
|
||||
from prometheus_client.parser import Sample, text_string_to_metric_families
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
Metric = None
|
||||
Sample = None
|
||||
|
||||
def text_string_to_metric_families(*args, **kwargs):
|
||||
raise ModuleNotFoundError("`prometheus_client` not found")
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class SignalActor:
|
||||
"""A Ray actor for coordinating test execution through signals.
|
||||
|
||||
Useful for testing async coordination, waiting for specific states,
|
||||
and synchronizing multiple actors or tasks in tests.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.ready_event = asyncio.Event()
|
||||
self.num_waiters = 0
|
||||
|
||||
def send(self, clear: bool = False):
|
||||
self.ready_event.set()
|
||||
if clear:
|
||||
self.ready_event.clear()
|
||||
|
||||
async def wait(self, should_wait: bool = True):
|
||||
if should_wait:
|
||||
self.num_waiters += 1
|
||||
await self.ready_event.wait()
|
||||
self.num_waiters -= 1
|
||||
|
||||
async def cur_num_waiters(self) -> int:
|
||||
return self.num_waiters
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class Semaphore:
|
||||
"""A Ray actor implementing a semaphore for test coordination.
|
||||
|
||||
Useful for testing resource limiting, concurrency control,
|
||||
and coordination between multiple actors or tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, value: int = 1):
|
||||
self._sema = asyncio.Semaphore(value=value)
|
||||
|
||||
async def acquire(self):
|
||||
await self._sema.acquire()
|
||||
|
||||
async def release(self):
|
||||
self._sema.release()
|
||||
|
||||
async def locked(self) -> bool:
|
||||
return self._sema.locked()
|
||||
|
||||
|
||||
__all__ = ["SignalActor", "Semaphore"]
|
||||
|
||||
|
||||
def wait_for_condition(
|
||||
condition_predictor: Callable[..., bool],
|
||||
timeout: float = 10,
|
||||
retry_interval_ms: float = 100,
|
||||
raise_exceptions: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Wait until a condition is met or time out with an exception.
|
||||
|
||||
Args:
|
||||
condition_predictor: A function that predicts the condition.
|
||||
timeout: Maximum timeout in seconds.
|
||||
retry_interval_ms: Retry interval in milliseconds.
|
||||
raise_exceptions: If true, exceptions that occur while executing
|
||||
condition_predictor won't be caught and instead will be raised.
|
||||
**kwargs: Arguments to pass to the condition_predictor.
|
||||
|
||||
Returns:
|
||||
None: Returns when the condition is met.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the condition is not met before the timeout expires.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
last_ex = None
|
||||
while time.monotonic() - start <= timeout:
|
||||
try:
|
||||
if condition_predictor(**kwargs):
|
||||
return
|
||||
except Exception:
|
||||
if raise_exceptions:
|
||||
raise
|
||||
last_ex = ray._private.utils.format_error_message(traceback.format_exc())
|
||||
time.sleep(retry_interval_ms / 1000.0)
|
||||
message = "The condition wasn't met before the timeout expired."
|
||||
if last_ex is not None:
|
||||
message += f" Last exception: {last_ex}"
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
async def async_wait_for_condition(
|
||||
condition_predictor: Callable[..., Awaitable[bool]],
|
||||
timeout: float = 10,
|
||||
retry_interval_ms: float = 100,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Wait until a condition is met or time out with an exception.
|
||||
|
||||
Args:
|
||||
condition_predictor: A function that predicts the condition.
|
||||
timeout: Maximum timeout in seconds.
|
||||
retry_interval_ms: Retry interval in milliseconds.
|
||||
**kwargs: Arguments to pass to the condition_predictor.
|
||||
|
||||
Returns:
|
||||
None: Returns when the condition is met.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the condition is not met before the timeout expires.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
last_ex = None
|
||||
while time.monotonic() - start <= timeout:
|
||||
try:
|
||||
if inspect.iscoroutinefunction(condition_predictor):
|
||||
if await condition_predictor(**kwargs):
|
||||
return
|
||||
else:
|
||||
if condition_predictor(**kwargs):
|
||||
return
|
||||
except Exception as ex:
|
||||
last_ex = ex
|
||||
await asyncio.sleep(retry_interval_ms / 1000.0)
|
||||
message = "The condition wasn't met before the timeout expired."
|
||||
if last_ex is not None:
|
||||
message += f" Last exception: {last_ex}"
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def simulate_s3_bucket(
|
||||
port: int = 5002,
|
||||
region: str = "us-west-2",
|
||||
) -> Iterator[str]:
|
||||
"""Context manager that simulates an S3 bucket and yields the URI.
|
||||
|
||||
Args:
|
||||
port: The port of the localhost endpoint where S3 is being served.
|
||||
region: The S3 region.
|
||||
|
||||
Yields:
|
||||
str: URI for the simulated S3 bucket.
|
||||
"""
|
||||
from moto.server import ThreadedMotoServer
|
||||
|
||||
old_env = os.environ
|
||||
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
|
||||
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
|
||||
os.environ["AWS_SECURITY_TOKEN"] = "testing"
|
||||
os.environ["AWS_SESSION_TOKEN"] = "testing"
|
||||
|
||||
s3_server = f"http://{build_address('localhost', port)}"
|
||||
server = ThreadedMotoServer(port=port)
|
||||
server.start()
|
||||
url = f"s3://{uuid.uuid4().hex}?region={region}&endpoint_override={s3_server}"
|
||||
yield url
|
||||
server.stop()
|
||||
os.environ = old_env
|
||||
|
||||
|
||||
class TelemetryCallsite(Enum):
|
||||
DRIVER = "driver"
|
||||
ACTOR = "actor"
|
||||
TASK = "task"
|
||||
|
||||
|
||||
def _get_library_usages() -> Set[str]:
|
||||
return set(
|
||||
ray_usage_lib.get_library_usages_to_report(
|
||||
ray.experimental.internal_kv.internal_kv_get_gcs_client()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_extra_usage_tags() -> Dict[str, str]:
|
||||
return ray_usage_lib.get_extra_usage_tags_to_report(
|
||||
ray.experimental.internal_kv.internal_kv_get_gcs_client()
|
||||
)
|
||||
|
||||
|
||||
def check_library_usage_telemetry(
|
||||
use_lib_fn: Callable[[], None],
|
||||
*,
|
||||
callsite: TelemetryCallsite,
|
||||
expected_library_usages: List[Set[str]],
|
||||
expected_extra_usage_tags: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Helper for writing tests to validate library usage telemetry.
|
||||
|
||||
`use_lib_fn` is a callable that will be called from the provided callsite.
|
||||
After calling it, the telemetry data to export will be validated against
|
||||
expected_library_usages and expected_extra_usage_tags.
|
||||
"""
|
||||
assert len(_get_library_usages()) == 0, _get_library_usages()
|
||||
|
||||
if callsite == TelemetryCallsite.DRIVER:
|
||||
use_lib_fn()
|
||||
elif callsite == TelemetryCallsite.ACTOR:
|
||||
|
||||
@ray.remote
|
||||
class A:
|
||||
def __init__(self):
|
||||
use_lib_fn()
|
||||
|
||||
a = A.remote()
|
||||
ray.get(a.__ray_ready__.remote())
|
||||
elif callsite == TelemetryCallsite.TASK:
|
||||
|
||||
@ray.remote
|
||||
def f():
|
||||
use_lib_fn()
|
||||
|
||||
ray.get(f.remote())
|
||||
else:
|
||||
assert False, f"Unrecognized callsite: {callsite}"
|
||||
|
||||
library_usages = _get_library_usages()
|
||||
extra_usage_tags = _get_extra_usage_tags()
|
||||
|
||||
assert library_usages in expected_library_usages, library_usages
|
||||
if expected_extra_usage_tags:
|
||||
assert all(
|
||||
[extra_usage_tags[k] == v for k, v in expected_extra_usage_tags.items()]
|
||||
), extra_usage_tags
|
||||
|
||||
|
||||
class FakeTimer:
|
||||
def __init__(self, start_time: Optional[float] = None):
|
||||
self._lock = threading.Lock()
|
||||
self.reset(start_time=start_time)
|
||||
|
||||
def reset(self, start_time: Optional[float] = None):
|
||||
with self._lock:
|
||||
if start_time is None:
|
||||
start_time = time.time()
|
||||
self._curr = start_time
|
||||
|
||||
def time(self) -> float:
|
||||
return self._curr
|
||||
|
||||
def advance(self, by: float):
|
||||
with self._lock:
|
||||
self._curr += by
|
||||
|
||||
def realistic_sleep(self, amt: float):
|
||||
with self._lock:
|
||||
self._curr += amt + 0.001
|
||||
|
||||
|
||||
def is_named_tuple(cls):
|
||||
"""Return True if cls is a namedtuple and False otherwise."""
|
||||
b = cls.__bases__
|
||||
if len(b) != 1 or b[0] is not tuple:
|
||||
return False
|
||||
f = getattr(cls, "_fields", None)
|
||||
if not isinstance(f, tuple):
|
||||
return False
|
||||
return all(type(n) is str for n in f)
|
||||
|
||||
|
||||
def assert_tensors_equivalent(obj1, obj2):
|
||||
"""
|
||||
Recursively compare objects with special handling for torch.Tensor.
|
||||
|
||||
Tensors are considered equivalent if:
|
||||
- Same dtype and shape
|
||||
- Same device type (e.g., both 'cpu' or both 'cuda'), index ignored
|
||||
- Values are equal (or close for floats)
|
||||
"""
|
||||
import torch
|
||||
|
||||
if isinstance(obj1, torch.Tensor) and isinstance(obj2, torch.Tensor):
|
||||
# 1. dtype
|
||||
assert obj1.dtype == obj2.dtype, f"dtype mismatch: {obj1.dtype} vs {obj2.dtype}"
|
||||
# 2. shape
|
||||
assert obj1.shape == obj2.shape, f"shape mismatch: {obj1.shape} vs {obj2.shape}"
|
||||
# 3. device type must match (cpu/cpu or cuda/cuda), ignore index
|
||||
assert (
|
||||
obj1.device.type == obj2.device.type
|
||||
), f"Device type mismatch: {obj1.device} vs {obj2.device}"
|
||||
|
||||
# 4. Compare values safely on CPU
|
||||
t1_cpu = obj1.cpu()
|
||||
t2_cpu = obj2.cpu()
|
||||
if obj1.dtype.is_floating_point or obj1.dtype.is_complex:
|
||||
assert torch.allclose(
|
||||
t1_cpu, t2_cpu, atol=1e-6, rtol=1e-5
|
||||
), "Floating-point tensors not close"
|
||||
else:
|
||||
assert torch.equal(t1_cpu, t2_cpu), "Integer/bool tensors not equal"
|
||||
return
|
||||
|
||||
# Type must match
|
||||
if type(obj1) is not type(obj2):
|
||||
raise AssertionError(f"Type mismatch: {type(obj1)} vs {type(obj2)}")
|
||||
|
||||
# Handle namedtuples
|
||||
if is_named_tuple(type(obj1)):
|
||||
assert len(obj1) == len(obj2)
|
||||
for a, b in zip(obj1, obj2):
|
||||
assert_tensors_equivalent(a, b)
|
||||
elif isinstance(obj1, dict):
|
||||
assert obj1.keys() == obj2.keys()
|
||||
for k in obj1:
|
||||
assert_tensors_equivalent(obj1[k], obj2[k])
|
||||
elif isinstance(obj1, (list, tuple)):
|
||||
assert len(obj1) == len(obj2)
|
||||
for a, b in zip(obj1, obj2):
|
||||
assert_tensors_equivalent(a, b)
|
||||
elif hasattr(obj1, "__dict__") and hasattr(obj2, "__dict__"):
|
||||
# Compare user-defined objects by their public attributes
|
||||
keys1 = {
|
||||
k
|
||||
for k in obj1.__dict__.keys()
|
||||
if not k.startswith("_ray_") and k != "_pytype_"
|
||||
}
|
||||
keys2 = {
|
||||
k
|
||||
for k in obj2.__dict__.keys()
|
||||
if not k.startswith("_ray_") and k != "_pytype_"
|
||||
}
|
||||
assert keys1 == keys2, f"Object attribute keys differ: {keys1} vs {keys2}"
|
||||
for k in keys1:
|
||||
assert_tensors_equivalent(obj1.__dict__[k], obj2.__dict__[k])
|
||||
else:
|
||||
# Fallback for primitives: int, float, str, bool, etc.
|
||||
assert obj1 == obj2, f"Non-tensor values differ: {obj1} vs {obj2}"
|
||||
|
||||
|
||||
def run_string_as_driver(
|
||||
driver_script: str, env: Dict = None, encode: str = "utf-8"
|
||||
) -> str:
|
||||
"""Run a driver as a separate process.
|
||||
|
||||
Args:
|
||||
driver_script: A string to run as a Python script.
|
||||
env: The environment variables for the driver.
|
||||
encode: The encoding to use for the driver script.
|
||||
|
||||
Returns:
|
||||
The script's output.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
with proc:
|
||||
output = proc.communicate(driver_script.encode(encoding=encode))[0]
|
||||
if proc.returncode:
|
||||
print(decode(output, encode_type=encode))
|
||||
logger.error(proc.stderr)
|
||||
raise subprocess.CalledProcessError(
|
||||
proc.returncode, proc.args, output, proc.stderr
|
||||
)
|
||||
out = decode(output, encode_type=encode)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricSamplePattern:
|
||||
name: Optional[str] = None
|
||||
value: Optional[str] = None
|
||||
partial_label_match: Optional[Dict[str, str]] = None
|
||||
|
||||
def matches(self, sample: "Sample"):
|
||||
if self.name is not None:
|
||||
if self.name != sample.name:
|
||||
return False
|
||||
|
||||
if self.value is not None:
|
||||
if self.value != sample.value:
|
||||
return False
|
||||
|
||||
if self.partial_label_match is not None:
|
||||
for label, value in self.partial_label_match.items():
|
||||
if sample.labels.get(label) != value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrometheusTimeseries:
|
||||
"""A collection of timeseries from multiple addresses. Each timeseries is a
|
||||
collection of samples with the same metric name and labels. Concretely:
|
||||
- components_dict: a dictionary of addresses to the Component labels
|
||||
- metric_descriptors: a dictionary of metric names to the Metric object
|
||||
- metric_samples: the latest value of each label
|
||||
"""
|
||||
|
||||
components_dict: Dict[str, Set[str]] = field(default_factory=dict)
|
||||
metric_descriptors: Dict[str, "Metric"] = field(default_factory=dict)
|
||||
metric_samples: Dict[frozenset, "Sample"] = field(default_factory=dict)
|
||||
|
||||
def flush(self):
|
||||
self.components_dict.clear()
|
||||
self.metric_descriptors.clear()
|
||||
self.metric_samples.clear()
|
||||
|
||||
|
||||
def fetch_raw_prometheus(prom_addresses, timeout=None):
|
||||
# Local import so minimal dependency tests can run without requests
|
||||
import requests
|
||||
|
||||
for address in prom_addresses:
|
||||
try:
|
||||
kwargs = {} if timeout is None else {"timeout": timeout}
|
||||
response = requests.get(f"http://{address}/metrics", **kwargs)
|
||||
yield address, response.text
|
||||
except requests.exceptions.ConnectionError:
|
||||
continue
|
||||
except requests.exceptions.Timeout:
|
||||
continue
|
||||
|
||||
|
||||
def fetch_prometheus(prom_addresses, timeout=None):
|
||||
components_dict = {}
|
||||
metric_descriptors = {}
|
||||
metric_samples = []
|
||||
|
||||
for address in prom_addresses:
|
||||
if address not in components_dict:
|
||||
components_dict[address] = set()
|
||||
|
||||
for address, response in fetch_raw_prometheus(prom_addresses, timeout=timeout):
|
||||
for metric in text_string_to_metric_families(response):
|
||||
for sample in metric.samples:
|
||||
metric_descriptors[sample.name] = metric
|
||||
metric_samples.append(sample)
|
||||
if "Component" in sample.labels:
|
||||
components_dict[address].add(sample.labels["Component"])
|
||||
return components_dict, metric_descriptors, metric_samples
|
||||
|
||||
|
||||
def fetch_prometheus_timeseries(
|
||||
prom_addreses: List[str],
|
||||
result: PrometheusTimeseries,
|
||||
timeout=None,
|
||||
) -> PrometheusTimeseries:
|
||||
components_dict, metric_descriptors, metric_samples = fetch_prometheus(
|
||||
prom_addreses, timeout=timeout
|
||||
)
|
||||
for address, components in components_dict.items():
|
||||
if address not in result.components_dict:
|
||||
result.components_dict[address] = set()
|
||||
result.components_dict[address].update(components)
|
||||
result.metric_descriptors.update(metric_descriptors)
|
||||
for sample in metric_samples:
|
||||
# udpate sample to the latest value
|
||||
result.metric_samples[
|
||||
frozenset(list(sample.labels.items()) + [("_metric_name_", sample.name)])
|
||||
] = sample
|
||||
return result
|
||||
|
||||
|
||||
def fetch_prometheus_metrics(prom_addresses: List[str]) -> Dict[str, List[Any]]:
|
||||
"""Return prometheus metrics from the given addresses.
|
||||
|
||||
Args:
|
||||
prom_addresses: List of metrics_agent addresses to collect metrics from.
|
||||
|
||||
Returns:
|
||||
Dict mapping from metric name to list of samples for the metric.
|
||||
"""
|
||||
_, _, samples = fetch_prometheus(prom_addresses)
|
||||
samples_by_name = defaultdict(list)
|
||||
for sample in samples:
|
||||
samples_by_name[sample.name].append(sample)
|
||||
return samples_by_name
|
||||
|
||||
|
||||
def fetch_prometheus_metric_timeseries(
|
||||
prom_addresses: List[str],
|
||||
result: PrometheusTimeseries,
|
||||
timeout=None,
|
||||
) -> Dict[str, List[Any]]:
|
||||
samples = fetch_prometheus_timeseries(
|
||||
prom_addresses, result, timeout=timeout
|
||||
).metric_samples.values()
|
||||
samples_by_name = defaultdict(list)
|
||||
for sample in samples:
|
||||
samples_by_name[sample.name].append(sample)
|
||||
return samples_by_name
|
||||
@@ -0,0 +1,53 @@
|
||||
load("@rules_python//python:defs.bzl", "py_library")
|
||||
load("//bazel:python.bzl", "py_test_module_list")
|
||||
|
||||
py_library(
|
||||
name = "conftest",
|
||||
srcs = glob(["**/conftest.py"]),
|
||||
visibility = [
|
||||
"//python/ray/_common/tests:__subpackages__",
|
||||
],
|
||||
deps = ["//python/ray/tests:conftest"],
|
||||
)
|
||||
|
||||
# Small tests.
|
||||
py_test_module_list(
|
||||
size = "small",
|
||||
files = [
|
||||
"test_deprecation.py",
|
||||
"test_filters.py",
|
||||
"test_formatters.py",
|
||||
"test_logging_constants.py",
|
||||
"test_network_utils.py",
|
||||
"test_ray_option_utils.py",
|
||||
"test_retry.py",
|
||||
"test_signal_semaphore_utils.py",
|
||||
"test_signature.py",
|
||||
"test_tls_utils.py",
|
||||
"test_utils.py",
|
||||
"test_wait_for_condition.py",
|
||||
],
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:core",
|
||||
],
|
||||
deps = [
|
||||
":conftest",
|
||||
"//:ray_lib",
|
||||
],
|
||||
)
|
||||
|
||||
py_test_module_list(
|
||||
size = "large",
|
||||
files = [
|
||||
"test_usage_stats.py",
|
||||
],
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:core",
|
||||
],
|
||||
deps = [
|
||||
":conftest",
|
||||
"//:ray_lib",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# Imports for filters and formatters tests
|
||||
pytest_plugins = ["ray.tests.conftest"]
|
||||
@@ -0,0 +1,97 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.deprecation import (
|
||||
DEPRECATED_VALUE,
|
||||
Deprecated,
|
||||
deprecation_warning,
|
||||
)
|
||||
|
||||
|
||||
def test_deprecation_warning_warn():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning:
|
||||
deprecation_warning("old_feature", "new_feature")
|
||||
|
||||
mock_warning.assert_called_once()
|
||||
args, _ = mock_warning.call_args
|
||||
assert (
|
||||
"DeprecationWarning: `old_feature` has been deprecated. Use `new_feature` instead."
|
||||
in args[0]
|
||||
)
|
||||
|
||||
|
||||
def test_deprecation_warning_error():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
deprecation_warning("old_feature", error=True)
|
||||
assert "`old_feature` has been deprecated." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_deprecated_decorator_function():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="old_func", new="new_func", error=False)
|
||||
def old_func():
|
||||
return "result"
|
||||
|
||||
result = old_func()
|
||||
assert result == "result"
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_class():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="OldClass", new="NewClass", error=False)
|
||||
class OldClass:
|
||||
pass
|
||||
|
||||
instance = OldClass()
|
||||
assert isinstance(instance, OldClass)
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_method():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
class MyClass:
|
||||
@Deprecated(old="old_method", new="new_method", error=False)
|
||||
def old_method(self):
|
||||
return "method_result"
|
||||
|
||||
instance = MyClass()
|
||||
result = instance.old_method()
|
||||
assert result == "method_result"
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_error():
|
||||
with patch("ray._common.deprecation.log_once") as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="old_func", error=True)
|
||||
def old_func():
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
old_func()
|
||||
|
||||
|
||||
def test_deprecated_value_constant():
|
||||
assert (
|
||||
DEPRECATED_VALUE == -1
|
||||
), f"DEPRECATED_VALUE should be -1, but got {DEPRECATED_VALUE}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,160 @@
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.filters import CoreContextFilter
|
||||
|
||||
|
||||
class TestCoreContextFilter:
|
||||
def test_driver_process(self, shutdown_only):
|
||||
log_context = ["job_id", "worker_id", "node_id"]
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
# Ray is not initialized so no context except PID which should be available
|
||||
for attr in log_context:
|
||||
assert not hasattr(record, attr)
|
||||
# PID should be available even when Ray is not initialized
|
||||
assert hasattr(record, "process")
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
ray.init()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in log_context:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
# This is not a worker process, so actor_id and task_id should not exist.
|
||||
for attr in ["actor_id", "task_id"]:
|
||||
assert not hasattr(record, attr)
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
def test_task_process(self, shutdown_only):
|
||||
@ray.remote
|
||||
def f():
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
should_exist = ["job_id", "worker_id", "node_id", "task_id", "process"]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"task_name": runtime_context.get_task_name(),
|
||||
"task_func_name": runtime_context.get_task_function_name(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert not hasattr(record, "actor_id")
|
||||
assert not hasattr(record, "actor_name")
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
obj_ref = f.remote()
|
||||
ray.get(obj_ref)
|
||||
|
||||
def test_actor_process(self, shutdown_only):
|
||||
@ray.remote
|
||||
class A:
|
||||
def f(self):
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
should_exist = [
|
||||
"job_id",
|
||||
"worker_id",
|
||||
"node_id",
|
||||
"actor_id",
|
||||
"task_id",
|
||||
"process",
|
||||
]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"actor_id": runtime_context.get_actor_id(),
|
||||
"actor_name": runtime_context.get_actor_name(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"task_name": runtime_context.get_task_name(),
|
||||
"task_func_name": runtime_context.get_task_function_name(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
# Record should not have the attribute with a value of an empty string.
|
||||
assert runtime_context.get_actor_name() == ""
|
||||
assert not hasattr(record, "actor_name")
|
||||
|
||||
actor = A.remote()
|
||||
ray.get(actor.f.remote())
|
||||
|
||||
def test_actor_process_with_thread(self, shutdown_only):
|
||||
@ray.remote
|
||||
class MockedRayDataWorker:
|
||||
def _check_log_record_in_thread(self):
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
|
||||
assert filter.filter(record)
|
||||
should_exist = [
|
||||
"job_id",
|
||||
"worker_id",
|
||||
"node_id",
|
||||
"actor_id",
|
||||
"task_id",
|
||||
"process",
|
||||
]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"actor_id": runtime_context.get_actor_id(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
# Record should not have the attribute with a value of an empty string.
|
||||
assert runtime_context.get_actor_name() == ""
|
||||
assert not hasattr(record, "actor_name")
|
||||
|
||||
assert runtime_context.get_task_name() == ""
|
||||
assert not hasattr(record, "task_name")
|
||||
|
||||
assert runtime_context.get_task_function_name() == ""
|
||||
assert not hasattr(record, "task_function_name")
|
||||
|
||||
return record
|
||||
|
||||
def map(self):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self._check_log_record_in_thread).result()
|
||||
|
||||
actor = MockedRayDataWorker.remote()
|
||||
ray.get(actor.map.remote())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.formatters import JSONFormatter, TextFormatter
|
||||
|
||||
|
||||
class TestJSONFormatter:
|
||||
def test_empty_record(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_exception(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({})
|
||||
try:
|
||||
raise ValueError("test")
|
||||
except ValueError:
|
||||
record.exc_info = sys.exc_info()
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"exc_text",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert "Traceback (most recent call last):" in record_dict["exc_text"]
|
||||
assert len(record_dict) == len(should_exist)
|
||||
|
||||
def test_record_with_user_provided_context(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({"user": "ray"})
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"user",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert record_dict["user"] == "ray"
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_flatten_keys_invalid_value(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({"ray_serve_extra_fields": "not_a_dict"})
|
||||
with pytest.raises(ValueError):
|
||||
formatter.format(record)
|
||||
|
||||
def test_record_with_flatten_keys_valid_dict(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord(
|
||||
{"ray_serve_extra_fields": {"key1": "value1", "key2": 2}}
|
||||
)
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"key1",
|
||||
"key2",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert record_dict["key1"] == "value1", record_dict
|
||||
assert record_dict["key2"] == 2
|
||||
assert "ray_serve_extra_fields" not in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
formatter.set_additional_log_standard_attrs(["name"])
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"timestamp_ns",
|
||||
"name",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
|
||||
|
||||
class TestTextFormatter:
|
||||
def test_record_with_user_provided_context(self):
|
||||
formatter = TextFormatter()
|
||||
record = logging.makeLogRecord({"user": "ray"})
|
||||
formatted = formatter.format(record)
|
||||
assert "user=ray" in formatted
|
||||
|
||||
def test_record_with_exception(self):
|
||||
formatter = TextFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=1000,
|
||||
msg="Test message",
|
||||
args=None,
|
||||
exc_info=None,
|
||||
)
|
||||
formatted = formatter.format(record)
|
||||
for s in ["INFO", "Test message", "test.py:1000", "--"]:
|
||||
assert s in formatted
|
||||
|
||||
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
|
||||
formatter = TextFormatter()
|
||||
formatter.set_additional_log_standard_attrs(["name"])
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
assert "name=" in formatted
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,83 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.logging_constants import (
|
||||
LOGGER_FLATTEN_KEYS,
|
||||
LOGRECORD_STANDARD_ATTRS,
|
||||
LogKey,
|
||||
)
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_is_frozenset():
|
||||
assert isinstance(LOGRECORD_STANDARD_ATTRS, frozenset)
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_contains_standard_names():
|
||||
expected = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
assert LOGRECORD_STANDARD_ATTRS == expected
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_has_expected_size():
|
||||
assert len(LOGRECORD_STANDARD_ATTRS) == 23
|
||||
|
||||
|
||||
def test_logger_flatten_keys_is_set():
|
||||
assert isinstance(LOGGER_FLATTEN_KEYS, set)
|
||||
assert "ray_serve_extra_fields" in LOGGER_FLATTEN_KEYS
|
||||
|
||||
|
||||
def test_logkey_is_enum():
|
||||
from enum import Enum
|
||||
|
||||
assert issubclass(LogKey, Enum)
|
||||
expected = {
|
||||
"JOB_ID": "job_id",
|
||||
"WORKER_ID": "worker_id",
|
||||
"NODE_ID": "node_id",
|
||||
"ACTOR_ID": "actor_id",
|
||||
"TASK_ID": "task_id",
|
||||
"ACTOR_NAME": "actor_name",
|
||||
"TASK_NAME": "task_name",
|
||||
"TASK_FUNCTION_NAME": "task_func_name",
|
||||
"ASCTIME": "asctime",
|
||||
"LEVELNAME": "levelname",
|
||||
"MESSAGE": "message",
|
||||
"FILENAME": "filename",
|
||||
"LINENO": "lineno",
|
||||
"EXC_TEXT": "exc_text",
|
||||
"PROCESS": "process",
|
||||
"TIMESTAMP_NS": "timestamp_ns",
|
||||
}
|
||||
actual = {member.name: member.value for member in LogKey}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.network_utils import is_localhost
|
||||
|
||||
|
||||
def test_is_localhost():
|
||||
assert is_localhost("localhost")
|
||||
assert is_localhost("127.0.0.1")
|
||||
assert is_localhost("::1")
|
||||
assert not is_localhost("8.8.8.8")
|
||||
assert not is_localhost("2001:db8::1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,197 @@
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.ray_option_utils import (
|
||||
Option,
|
||||
_check_deprecate_placement_group,
|
||||
_counting_option,
|
||||
_resource_option,
|
||||
_validate_resource_quantity,
|
||||
_validate_resources,
|
||||
update_options,
|
||||
validate_actor_options,
|
||||
validate_task_options,
|
||||
)
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
|
||||
|
||||
class TestOptionValidation:
|
||||
def test_option_validate(self):
|
||||
opt = Option(
|
||||
type_constraint=int, value_constraint=lambda v: "error" if v < 0 else None
|
||||
)
|
||||
opt.validate("test", 1)
|
||||
with pytest.raises(TypeError):
|
||||
opt.validate("test", "a")
|
||||
with pytest.raises(ValueError, match="error"):
|
||||
opt.validate("test", -1)
|
||||
|
||||
def test_counting_option(self):
|
||||
# Test infinite counting option
|
||||
opt_inf = _counting_option("test_inf", infinite=True)
|
||||
opt_inf.validate("test_inf", 5)
|
||||
opt_inf.validate("test_inf", 0)
|
||||
opt_inf.validate("test_inf", -1) # Represents infinity
|
||||
opt_inf.validate("test_inf", None)
|
||||
with pytest.raises(ValueError):
|
||||
opt_inf.validate("test_inf", -2)
|
||||
with pytest.raises(TypeError):
|
||||
opt_inf.validate("test_inf", 1.5)
|
||||
|
||||
# Test non-infinite counting option
|
||||
opt_non_inf = _counting_option("test_non_inf", infinite=False)
|
||||
opt_non_inf.validate("test_non_inf", 5)
|
||||
opt_non_inf.validate("test_non_inf", 0)
|
||||
opt_non_inf.validate("test_non_inf", None)
|
||||
with pytest.raises(ValueError):
|
||||
opt_non_inf.validate("test_non_inf", -1)
|
||||
|
||||
@patch("ray._raylet.RESOURCE_UNIT_SCALING", 10000)
|
||||
@patch(
|
||||
"ray._private.accelerators.get_all_accelerator_resource_names",
|
||||
return_value={"GPU", "TPU"},
|
||||
)
|
||||
@patch("ray._private.accelerators.get_accelerator_manager_for_resource")
|
||||
def test_validate_resource_quantity(self, mock_get_manager, mock_get_all_names):
|
||||
# Valid cases
|
||||
assert _validate_resource_quantity("CPU", 1) is None
|
||||
assert _validate_resource_quantity("memory", 0) is None
|
||||
assert _validate_resource_quantity("custom", 0.5) is None
|
||||
|
||||
# Invalid cases
|
||||
err = _validate_resource_quantity("CPU", -1)
|
||||
assert isinstance(err, str)
|
||||
assert "cannot be negative" in err
|
||||
err = _validate_resource_quantity("CPU", 0.00001)
|
||||
assert isinstance(err, str)
|
||||
assert "cannot go beyond 0.0001" in err
|
||||
|
||||
# Accelerator validation
|
||||
mock_manager_instance = mock_get_manager.return_value
|
||||
mock_manager_instance.validate_resource_request_quantity.return_value = (
|
||||
False,
|
||||
"mock error",
|
||||
)
|
||||
err = _validate_resource_quantity("GPU", 1.5)
|
||||
assert isinstance(err, str)
|
||||
assert "mock error" in err
|
||||
mock_get_manager.assert_called_with("GPU")
|
||||
mock_manager_instance.validate_resource_request_quantity.assert_called_with(1.5)
|
||||
|
||||
mock_manager_instance.validate_resource_request_quantity.return_value = (
|
||||
True,
|
||||
"",
|
||||
)
|
||||
assert _validate_resource_quantity("TPU", 1) is None
|
||||
|
||||
def test_resource_option(self):
|
||||
opt = _resource_option("CPU")
|
||||
opt.validate("CPU", 1)
|
||||
opt.validate("CPU", 0.5)
|
||||
opt.validate("CPU", None)
|
||||
with pytest.raises(TypeError):
|
||||
opt.validate("CPU", "1")
|
||||
with pytest.raises(ValueError):
|
||||
opt.validate("CPU", -1.0)
|
||||
|
||||
def test_validate_resources(self):
|
||||
assert _validate_resources(None) is None
|
||||
assert _validate_resources({"custom": 1}) is None
|
||||
err = _validate_resources({"CPU": 1, "GPU": 1})
|
||||
assert isinstance(err, str)
|
||||
assert "Use the 'num_cpus' and 'num_gpus' keyword" in err
|
||||
err = _validate_resources({"custom": -1})
|
||||
assert isinstance(err, str)
|
||||
assert "cannot be negative" in err
|
||||
|
||||
|
||||
class TestTaskActorOptionValidation:
|
||||
def test_validate_task_options_valid(self):
|
||||
validate_task_options({"num_cpus": 2, "max_retries": 3}, in_options=False)
|
||||
|
||||
def test_validate_task_options_invalid_keyword(self):
|
||||
with pytest.raises(ValueError, match="Invalid option keyword"):
|
||||
validate_task_options({"invalid_option": 1}, in_options=False)
|
||||
|
||||
def test_validate_task_options_in_options_invalid(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("Setting 'max_calls' is not supported in '.options()'."),
|
||||
):
|
||||
validate_task_options({"max_calls": 5}, in_options=True)
|
||||
|
||||
def test_validate_actor_options_valid(self):
|
||||
validate_actor_options({"max_concurrency": 2, "name": "abc"}, in_options=False)
|
||||
|
||||
def test_validate_actor_options_invalid_keyword(self):
|
||||
with pytest.raises(ValueError, match="Invalid option keyword"):
|
||||
validate_actor_options({"invalid_option": 1}, in_options=False)
|
||||
|
||||
def test_validate_actor_options_in_options_invalid(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"Setting 'concurrency_groups' is not supported in '.options()'."
|
||||
),
|
||||
):
|
||||
validate_actor_options({"concurrency_groups": {}}, in_options=True)
|
||||
|
||||
def test_validate_actor_get_if_exists_no_name(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="must be specified to use `get_if_exists`"
|
||||
):
|
||||
validate_actor_options({"get_if_exists": True}, in_options=False)
|
||||
|
||||
def test_validate_actor_object_store_memory_warning(self):
|
||||
with pytest.warns(
|
||||
DeprecationWarning,
|
||||
match="Setting 'object_store_memory' for actors is deprecated",
|
||||
):
|
||||
validate_actor_options({"object_store_memory": 100}, in_options=False)
|
||||
|
||||
def test_check_deprecate_placement_group(self):
|
||||
pg = PlacementGroup.empty()
|
||||
# No error if only one is specified
|
||||
_check_deprecate_placement_group({"placement_group": pg})
|
||||
_check_deprecate_placement_group({"scheduling_strategy": "SPREAD"})
|
||||
|
||||
# Error if both are specified
|
||||
with pytest.raises(
|
||||
ValueError, match="Placement groups should be specified via"
|
||||
):
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": pg, "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
|
||||
# Check no error with default or None placement_group
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": "default", "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": None, "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateOptions:
|
||||
def test_simple_update(self):
|
||||
original = {"num_cpus": 1, "name": "a"}
|
||||
new = {"num_cpus": 2, "num_gpus": 1}
|
||||
updated = update_options(original, new)
|
||||
assert updated == {"num_cpus": 2, "name": "a", "num_gpus": 1}
|
||||
|
||||
def test_update_with_empty_new(self):
|
||||
original = {"num_cpus": 1}
|
||||
updated = update_options(original, {})
|
||||
assert updated == original
|
||||
|
||||
def test_update_empty_original(self):
|
||||
new = {"num_cpus": 1}
|
||||
updated = update_options({}, new)
|
||||
assert updated == new
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,136 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.retry import (
|
||||
call_with_retry,
|
||||
retry,
|
||||
)
|
||||
|
||||
|
||||
def test_call_with_retry_immediate_success_with_args():
|
||||
def func(a, b):
|
||||
return [a, b]
|
||||
|
||||
assert call_with_retry(func, "func", [], 1, 0, "a", "b") == ["a", "b"]
|
||||
|
||||
|
||||
def test_retry_immediate_success_with_object_args():
|
||||
class MyClass:
|
||||
@retry("func", [], 1, 0)
|
||||
def func(self, a, b):
|
||||
return [a, b]
|
||||
|
||||
assert MyClass().func("a", "b") == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_last_attempt_successful_with_appropriate_wait_time(
|
||||
monkeypatch, use_decorator
|
||||
):
|
||||
sleep_total = 0
|
||||
|
||||
def sleep(x):
|
||||
nonlocal sleep_total
|
||||
sleep_total += x
|
||||
|
||||
monkeypatch.setattr("time.sleep", sleep)
|
||||
monkeypatch.setattr("random.uniform", lambda a, b: 1)
|
||||
|
||||
pattern = "have not reached 4th attempt"
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 4:
|
||||
return "success"
|
||||
raise ValueError(pattern)
|
||||
|
||||
args = ["func", [pattern], 4, 3]
|
||||
if use_decorator:
|
||||
assert retry(*args)(func)() == "success"
|
||||
else:
|
||||
assert call_with_retry(func, *args) == "success"
|
||||
assert sleep_total == 6 # 1 + 2 + 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_unretryable_error(use_decorator):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("unretryable error")
|
||||
|
||||
args = ["func", ["only retryable error"], 10, 0]
|
||||
with pytest.raises(ValueError, match="unretryable error"):
|
||||
if use_decorator:
|
||||
retry(*args)(func)()
|
||||
else:
|
||||
call_with_retry(func, *args)
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_fail_all_attempts_retry_all_errors(use_decorator):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError(str(call_count))
|
||||
|
||||
args = ["func", None, 3, 0]
|
||||
with pytest.raises(ValueError):
|
||||
if use_decorator:
|
||||
retry(*args)(func)()
|
||||
else:
|
||||
call_with_retry(func, *args)
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
def test_call_with_retry_matches_class_name():
|
||||
"""Patterns can match the exception class name (e.g., 'RateLimit')."""
|
||||
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RateLimitError("Error code: 429")
|
||||
|
||||
with pytest.raises(RateLimitError):
|
||||
call_with_retry(func, "func", ["RateLimit"], 3, 0)
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pattern,should_retry",
|
||||
[
|
||||
# Valid regex that is not a literal substring, that matches via regex search
|
||||
(r"\d{3}", True),
|
||||
# Invalid regex, re.error is handled by returning False and the error is not retried.
|
||||
(r"[unclosed", False),
|
||||
],
|
||||
)
|
||||
def test_call_with_retry_regex_matching(pattern, should_retry):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("Error code: 429")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
call_with_retry(func, "func", [pattern], 3, 0)
|
||||
|
||||
assert call_count == (3 if should_retry else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for Ray test utility classes.
|
||||
|
||||
This module contains pytest-based tests for SignalActor and Semaphore classes
|
||||
from ray._common.test_utils. These test utility classes are used for coordination
|
||||
and synchronization in Ray tests.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import Semaphore, SignalActor, wait_for_condition
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_init():
|
||||
"""Initialize Ray for the test module."""
|
||||
ray.init(num_cpus=4)
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_signal_actor_basic(ray_init):
|
||||
"""Test basic SignalActor functionality - send and wait operations."""
|
||||
signal = SignalActor.remote()
|
||||
|
||||
# Test initial state
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
# Test send and wait
|
||||
ray.get(signal.send.remote())
|
||||
signal.wait.remote()
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
|
||||
def test_signal_actor_multiple_waiters(ray_init):
|
||||
"""Test SignalActor with multiple waiters and signal clearing."""
|
||||
signal = SignalActor.remote()
|
||||
|
||||
# Create multiple waiters
|
||||
for _ in range(3):
|
||||
signal.wait.remote()
|
||||
|
||||
# Check number of waiters
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 3)
|
||||
|
||||
# Send signal and wait for all waiters
|
||||
ray.get(signal.send.remote())
|
||||
|
||||
# Verify all waiters are done
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 0)
|
||||
|
||||
# check that .wait() doesn't block if the signal is already sent
|
||||
ray.get(signal.wait.remote())
|
||||
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
# clear the signal
|
||||
ray.get(signal.send.remote(clear=True))
|
||||
signal.wait.remote()
|
||||
# Verify all waiters are done
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
||||
|
||||
ray.get(signal.send.remote())
|
||||
|
||||
|
||||
def test_semaphore_basic(ray_init):
|
||||
"""Test basic Semaphore functionality - acquire, release, and lock status."""
|
||||
sema = Semaphore.remote(value=2)
|
||||
|
||||
# Test initial state
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
# Test acquire and release
|
||||
ray.get(sema.acquire.remote())
|
||||
ray.get(sema.acquire.remote())
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is True)
|
||||
|
||||
ray.get(sema.release.remote())
|
||||
ray.get(sema.release.remote())
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
|
||||
def test_semaphore_concurrent(ray_init):
|
||||
"""Test Semaphore with concurrent workers to verify resource limiting."""
|
||||
sema = Semaphore.remote(value=2)
|
||||
|
||||
def worker():
|
||||
ray.get(sema.acquire.remote())
|
||||
time.sleep(0.1)
|
||||
ray.get(sema.release.remote())
|
||||
|
||||
# Create multiple workers
|
||||
_ = [worker() for _ in range(4)]
|
||||
|
||||
# Verify semaphore is not locked
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,515 @@
|
||||
"""Tests for Ray signature utility functions.
|
||||
|
||||
This module contains pytest-based tests for signature-related functions in
|
||||
ray._common.signature. These functions are used for extracting, validating,
|
||||
and flattening function signatures for serialization.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.signature import (
|
||||
DUMMY_TYPE,
|
||||
extract_signature,
|
||||
flatten_args,
|
||||
get_signature,
|
||||
recover_args,
|
||||
validate_args,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSignature:
|
||||
"""Tests for the get_signature utility function."""
|
||||
|
||||
def test_regular_function(self):
|
||||
"""Test getting signature from a regular Python function."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 4
|
||||
assert "a" in sig.parameters
|
||||
assert "b" in sig.parameters
|
||||
assert sig.parameters["b"].default == 10
|
||||
|
||||
def test_function_with_annotations(self):
|
||||
"""Test getting signature from a function with type annotations."""
|
||||
|
||||
def test_func(a: int, b: str = "default") -> str:
|
||||
return f"{a}{b}"
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 2
|
||||
assert sig.parameters["a"].annotation is int
|
||||
assert sig.parameters["b"].annotation is str
|
||||
assert sig.parameters["b"].default == "default"
|
||||
|
||||
def test_function_no_parameters(self):
|
||||
"""Test getting signature from a function with no parameters."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 0
|
||||
|
||||
def test_lambda_function(self):
|
||||
"""Test getting signature from a lambda function."""
|
||||
sig = get_signature(lambda x, y=5: x + y)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 2 # x, y
|
||||
assert sig.parameters["y"].default == 5
|
||||
|
||||
@patch("ray._common.signature.is_cython")
|
||||
def test_cython_function_with_attributes(self, mock_is_cython):
|
||||
"""Test getting signature from a Cython function with required attributes."""
|
||||
mock_is_cython.return_value = True
|
||||
|
||||
def original_func(x=10):
|
||||
return x
|
||||
|
||||
mock_func = Mock()
|
||||
mock_func.__code__ = original_func.__code__
|
||||
mock_func.__annotations__ = original_func.__annotations__
|
||||
mock_func.__defaults__ = original_func.__defaults__
|
||||
mock_func.__kwdefaults__ = original_func.__kwdefaults__
|
||||
|
||||
sig = get_signature(mock_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 1
|
||||
assert "x" in sig.parameters
|
||||
|
||||
@patch("ray._common.signature.is_cython")
|
||||
def test_cython_function_missing_attributes(self, mock_is_cython):
|
||||
"""Test error handling for Cython function missing required attributes."""
|
||||
mock_is_cython.return_value = True
|
||||
|
||||
# Create a mock Cython function missing required attributes
|
||||
mock_func = Mock()
|
||||
del mock_func.__code__ # Remove required attribute
|
||||
|
||||
with pytest.raises(TypeError, match="is not a Python function we can process"):
|
||||
get_signature(mock_func)
|
||||
|
||||
def test_method_signature(self):
|
||||
"""Test getting signature from a class method."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a, b=20):
|
||||
return a + b
|
||||
|
||||
sig = get_signature(TestClass.test_method)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 3 # self, a, b
|
||||
assert "self" in sig.parameters
|
||||
assert "a" in sig.parameters
|
||||
assert "b" in sig.parameters
|
||||
assert sig.parameters["b"].default == 20
|
||||
|
||||
|
||||
class TestExtractSignature:
|
||||
"""Tests for the extract_signature utility function."""
|
||||
|
||||
def test_function_without_ignore_first(self):
|
||||
"""Test extracting signature from function without ignoring first parameter."""
|
||||
|
||||
def test_func(a, b=10, c=None):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func, ignore_first=False)
|
||||
assert len(params) == 3
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[1].default == 10
|
||||
assert params[2].name == "c"
|
||||
assert params[2].default is None
|
||||
|
||||
def test_method_with_ignore_first(self):
|
||||
"""Test extracting signature from method ignoring 'self' parameter."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a, b=20):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[1].default == 20
|
||||
|
||||
def test_function_with_ignore_first(self):
|
||||
"""Test extracting signature from regular function with ignore_first=True."""
|
||||
|
||||
def test_func(x, y, z=30):
|
||||
return x + y + z
|
||||
|
||||
params = extract_signature(test_func, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "y"
|
||||
assert params[1].name == "z"
|
||||
assert params[1].default == 30
|
||||
|
||||
def test_empty_parameters_with_ignore_first(self):
|
||||
"""Test error handling when method has no parameters but ignore_first=True."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
with pytest.raises(ValueError, match="Methods must take a 'self' argument"):
|
||||
extract_signature(test_func, ignore_first=True)
|
||||
|
||||
def test_single_parameter_with_ignore_first(self):
|
||||
"""Test extracting signature from method with only 'self' parameter."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self):
|
||||
return "hello"
|
||||
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 0
|
||||
|
||||
def test_varargs_and_kwargs(self):
|
||||
"""Test extracting signature with *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func, ignore_first=False)
|
||||
assert len(params) == 4
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[2].name == "args"
|
||||
assert params[2].kind == inspect.Parameter.VAR_POSITIONAL
|
||||
assert params[3].name == "kwargs"
|
||||
assert params[3].kind == inspect.Parameter.VAR_KEYWORD
|
||||
|
||||
|
||||
class TestValidateArgs:
|
||||
"""Tests for the validate_args utility function."""
|
||||
|
||||
def test_valid_positional_args(self):
|
||||
"""Test validation with valid positional arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1, 2), {})
|
||||
validate_args(params, (1, 2, 3), {})
|
||||
|
||||
def test_valid_keyword_args(self):
|
||||
"""Test validation with valid keyword arguments."""
|
||||
|
||||
def test_func(a, b=20, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1,), {"b": 2})
|
||||
validate_args(params, (1,), {"b": 2, "c": 3})
|
||||
validate_args(params, (), {"a": 1, "b": 2, "c": 3})
|
||||
|
||||
def test_valid_mixed_args(self):
|
||||
"""Test validation with mixed positional and keyword arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1,), {"b": 2})
|
||||
validate_args(params, (1, 2), {"c": 3})
|
||||
|
||||
def test_too_many_positional_args(self):
|
||||
"""Test error handling for too many positional arguments."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2, 3), {})
|
||||
|
||||
def test_missing_required_args(self):
|
||||
"""Test error handling for missing required arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1,), {}) # Missing 'b'
|
||||
|
||||
def test_unexpected_keyword_args(self):
|
||||
"""Test error handling for unexpected keyword arguments."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2), {"c": 3})
|
||||
|
||||
def test_duplicate_args(self):
|
||||
"""Test error handling for duplicate arguments (positional and keyword)."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2), {"b": 3}) # 'b' specified twice
|
||||
|
||||
def test_varargs_validation(self):
|
||||
"""Test validation with *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=20, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1, 2, 3, 4), {"extra": 5})
|
||||
validate_args(params, (1,), {"b": 2, "extra": 3})
|
||||
|
||||
|
||||
class TestFlattenArgs:
|
||||
"""Tests for the flatten_args utility function."""
|
||||
|
||||
def test_only_positional_args(self):
|
||||
"""Test flattening with only positional arguments."""
|
||||
|
||||
def test_func(a, b, c):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (1, 2, 3), {})
|
||||
|
||||
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
|
||||
assert flattened == expected
|
||||
|
||||
def test_only_keyword_args(self):
|
||||
"""Test flattening with only keyword arguments."""
|
||||
|
||||
def test_func(a=1, b=2, c=3):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (), {"a": 10, "b": 20, "c": 30})
|
||||
|
||||
expected = ["a", 10, "b", 20, "c", 30]
|
||||
assert flattened == expected
|
||||
|
||||
def test_mixed_args(self):
|
||||
"""Test flattening with mixed positional and keyword arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (1, 2), {"c": 3})
|
||||
|
||||
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
|
||||
assert flattened == expected
|
||||
|
||||
def test_empty_args(self):
|
||||
"""Test flattening with no arguments."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (), {})
|
||||
|
||||
assert flattened == []
|
||||
|
||||
def test_complex_types(self):
|
||||
"""Test flattening with complex argument types."""
|
||||
|
||||
def test_func(a, b, c=None):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
complex_args = ([1, 2, 3], {"key": "value"})
|
||||
complex_kwargs = {"c": {"nested": "dict"}}
|
||||
|
||||
flattened = flatten_args(params, complex_args, complex_kwargs)
|
||||
|
||||
expected = [
|
||||
DUMMY_TYPE,
|
||||
[1, 2, 3],
|
||||
DUMMY_TYPE,
|
||||
{"key": "value"},
|
||||
"c",
|
||||
{"nested": "dict"},
|
||||
]
|
||||
assert flattened == expected
|
||||
|
||||
def test_invalid_args_raises_error(self):
|
||||
"""Test that invalid arguments raise TypeError during flattening."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
flatten_args(params, (1, 2, 3), {}) # Too many args
|
||||
|
||||
|
||||
class TestRecoverArgs:
|
||||
"""Tests for the recover_args utility function."""
|
||||
|
||||
def test_only_positional_args(self):
|
||||
"""Test recovering only positional arguments."""
|
||||
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [1, 2, 3]
|
||||
assert kwargs == {}
|
||||
|
||||
def test_only_keyword_args(self):
|
||||
"""Test recovering only keyword arguments."""
|
||||
flattened = ["a", 10, "b", 20, "c", 30]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == []
|
||||
assert kwargs == {"a": 10, "b": 20, "c": 30}
|
||||
|
||||
def test_mixed_args(self):
|
||||
"""Test recovering mixed positional and keyword arguments."""
|
||||
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [1, 2]
|
||||
assert kwargs == {"c": 3}
|
||||
|
||||
def test_empty_flattened(self):
|
||||
"""Test recovering from empty flattened list."""
|
||||
flattened = []
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
|
||||
def test_complex_types(self):
|
||||
"""Test recovering complex argument types."""
|
||||
flattened = [
|
||||
DUMMY_TYPE,
|
||||
[1, 2, 3],
|
||||
DUMMY_TYPE,
|
||||
{"key": "value"},
|
||||
"c",
|
||||
{"nested": "dict"},
|
||||
]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [[1, 2, 3], {"key": "value"}]
|
||||
assert kwargs == {"c": {"nested": "dict"}}
|
||||
|
||||
def test_invalid_odd_length(self):
|
||||
"""Test error handling for odd-length flattened list."""
|
||||
flattened = [DUMMY_TYPE, 1, "key"] # Odd length
|
||||
with pytest.raises(
|
||||
AssertionError, match="Flattened arguments need to be even-numbered"
|
||||
):
|
||||
recover_args(flattened)
|
||||
|
||||
def test_preserve_order(self):
|
||||
"""Test that argument order is preserved during flatten/recover."""
|
||||
|
||||
def test_func(a, b, c, d, e):
|
||||
return a + b + c + d + e
|
||||
|
||||
params = extract_signature(test_func)
|
||||
original_args = (1, 2, 3)
|
||||
original_kwargs = {"d": 4, "e": 5}
|
||||
|
||||
flattened = flatten_args(params, original_args, original_kwargs)
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
|
||||
assert recovered_args == [1, 2, 3]
|
||||
assert recovered_kwargs == {"d": 4, "e": 5}
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for signature utilities working together."""
|
||||
|
||||
def test_complete_workflow(self):
|
||||
"""Test complete workflow from function to flatten/recover."""
|
||||
|
||||
def test_func(x: int, y: str = "default", z: Optional[Any] = None):
|
||||
return f"{x}_{y}_{z}"
|
||||
|
||||
# Extract signature
|
||||
params = extract_signature(test_func)
|
||||
assert len(params) == 3
|
||||
|
||||
# Validate arguments
|
||||
args = (42, "hello")
|
||||
kwargs = {"z": [1, 2, 3]}
|
||||
validate_args(params, args, kwargs)
|
||||
|
||||
# Flatten arguments
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
expected = [DUMMY_TYPE, 42, DUMMY_TYPE, "hello", "z", [1, 2, 3]]
|
||||
assert flattened == expected
|
||||
|
||||
# Recover arguments
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
def test_method_workflow_with_ignore_first(self):
|
||||
"""Test complete workflow for class methods with ignore_first=True."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a: int, b: str = "test"):
|
||||
return f"{a}_{b}"
|
||||
|
||||
# Extract signature ignoring 'self'
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
|
||||
# Validate and flatten
|
||||
args = (100,)
|
||||
kwargs = {"b": "custom"}
|
||||
validate_args(params, args, kwargs)
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
|
||||
# Recover and verify
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
def test_varargs_kwargs_workflow(self):
|
||||
"""Test workflow with functions that have *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b + sum(args) + sum(kwargs.values())
|
||||
|
||||
params = extract_signature(test_func)
|
||||
|
||||
# Test with extra positional and keyword arguments
|
||||
args = (1, 2, 3, 4, 5)
|
||||
kwargs = {"extra1": 10, "extra2": 20}
|
||||
|
||||
validate_args(params, args, kwargs)
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
@@ -0,0 +1,41 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.tls_utils import generate_self_signed_tls_certs
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_returns_tuple():
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
assert isinstance(cert_contents, str)
|
||||
assert isinstance(key_contents, str)
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_pem_format():
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
assert cert_contents.strip().startswith("-----BEGIN CERTIFICATE-----")
|
||||
assert cert_contents.strip().endswith("-----END CERTIFICATE-----")
|
||||
assert key_contents.strip().startswith("-----BEGIN")
|
||||
assert "PRIVATE KEY" in key_contents
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_usable_for_ssl():
|
||||
import ssl
|
||||
import tempfile
|
||||
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(mode="w", suffix=".crt") as cf,
|
||||
tempfile.NamedTemporaryFile(mode="w", suffix=".key") as kf,
|
||||
):
|
||||
cf.write(cert_contents)
|
||||
cf.flush()
|
||||
kf.write(key_contents)
|
||||
kf.flush()
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(cf.name, kf.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
"""Tests for Ray utility functions.
|
||||
|
||||
This module contains pytest-based tests for utility functions in ray._common.utils.
|
||||
Test utility classes (SignalActor, Semaphore) are in ray._common.test_utils to
|
||||
ensure they're included in the Ray package distribution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.utils import (
|
||||
_BACKGROUND_TASKS,
|
||||
env_bool,
|
||||
get_or_create_event_loop,
|
||||
get_system_memory,
|
||||
load_class,
|
||||
run_background_task,
|
||||
try_to_create_directory,
|
||||
)
|
||||
|
||||
# Optional imports for testing
|
||||
try:
|
||||
import psutil
|
||||
|
||||
PSUTIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PSUTIL_AVAILABLE = False
|
||||
|
||||
|
||||
class TestGetOrCreateEventLoop:
|
||||
"""Tests for the get_or_create_event_loop utility function."""
|
||||
|
||||
def test_existing_event_loop(self):
|
||||
# With running event loop
|
||||
expect_loop = asyncio.new_event_loop()
|
||||
expect_loop.set_debug(True)
|
||||
asyncio.set_event_loop(expect_loop)
|
||||
with warnings.catch_warnings():
|
||||
# Assert no deprecating warnings raised for python>=3.10.
|
||||
warnings.simplefilter("error")
|
||||
actual_loop = get_or_create_event_loop()
|
||||
|
||||
assert actual_loop == expect_loop, "Loop should not be recreated."
|
||||
|
||||
def test_new_event_loop(self):
|
||||
with warnings.catch_warnings():
|
||||
# Assert no deprecating warnings raised for python>=3.10.
|
||||
warnings.simplefilter("error")
|
||||
loop = get_or_create_event_loop()
|
||||
assert loop is not None, "new event loop should be created."
|
||||
|
||||
|
||||
class TestEnvBool:
|
||||
"""Tests for the env_bool utility function."""
|
||||
|
||||
_KEY = "_RAY_TEST_ENV_BOOL"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_value, expected",
|
||||
[
|
||||
("1", True),
|
||||
("true", True),
|
||||
("True", True),
|
||||
("TRUE", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("FALSE", False),
|
||||
("yes", False),
|
||||
("no", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_env_bool_values(self, env_value, expected, monkeypatch):
|
||||
monkeypatch.setenv(self._KEY, env_value)
|
||||
assert env_bool(self._KEY, False) is expected
|
||||
|
||||
def test_env_bool_default_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv(self._KEY, raising=False)
|
||||
assert env_bool(self._KEY, False) is False
|
||||
assert env_bool(self._KEY, True) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_background_task():
|
||||
"""Test the run_background_task utility function."""
|
||||
result = {}
|
||||
|
||||
async def co():
|
||||
result["start"] = 1
|
||||
await asyncio.sleep(0)
|
||||
result["end"] = 1
|
||||
|
||||
run_background_task(co())
|
||||
|
||||
# Background task is running.
|
||||
assert len(_BACKGROUND_TASKS) == 1
|
||||
# co executed.
|
||||
await asyncio.sleep(0)
|
||||
# await asyncio.sleep(0) from co is reached.
|
||||
await asyncio.sleep(0)
|
||||
# co finished and callback called.
|
||||
await asyncio.sleep(0)
|
||||
# The task should be removed from the set once it finishes.
|
||||
assert len(_BACKGROUND_TASKS) == 0
|
||||
|
||||
assert result.get("start") == 1
|
||||
assert result.get("end") == 1
|
||||
|
||||
|
||||
class TestTryToCreateDirectory:
|
||||
"""Tests for the try_to_create_directory utility function."""
|
||||
|
||||
def test_create_new_directory(self):
|
||||
"""Test creating a new directory."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_dir = os.path.join(temp_dir, "test_dir")
|
||||
try_to_create_directory(test_dir)
|
||||
assert os.path.exists(test_dir), "Directory should be created"
|
||||
assert os.path.isdir(test_dir), "Path should be a directory"
|
||||
|
||||
def test_existing_directory(self):
|
||||
"""Test creating a directory that already exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_dir = os.path.join(temp_dir, "existing_dir")
|
||||
# Create directory first
|
||||
os.makedirs(test_dir)
|
||||
# Should work without error
|
||||
try_to_create_directory(test_dir)
|
||||
assert os.path.exists(test_dir), "Directory should still exist"
|
||||
|
||||
def test_nested_directory_creation(self):
|
||||
"""Test creating nested directory structure."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
nested_dir = os.path.join(temp_dir, "nested", "deep", "structure")
|
||||
try_to_create_directory(nested_dir)
|
||||
assert os.path.exists(nested_dir), "Nested directory should be created"
|
||||
|
||||
def test_tilde_expansion(self):
|
||||
"""Test directory creation with tilde expansion."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fake_home = os.path.join(temp_dir, "fake_home")
|
||||
os.makedirs(fake_home, exist_ok=True)
|
||||
|
||||
# Mock the expanduser for this test
|
||||
original_expanduser = os.path.expanduser
|
||||
os.path.expanduser = (
|
||||
lambda path: path.replace("~", fake_home)
|
||||
if path.startswith("~")
|
||||
else path
|
||||
)
|
||||
|
||||
try:
|
||||
tilde_dir = "~/test_tilde_dir"
|
||||
try_to_create_directory(tilde_dir)
|
||||
expected_path = os.path.join(fake_home, "test_tilde_dir")
|
||||
assert os.path.exists(
|
||||
expected_path
|
||||
), "Tilde-expanded directory should be created"
|
||||
finally:
|
||||
# Restore original expanduser
|
||||
os.path.expanduser = original_expanduser
|
||||
|
||||
|
||||
class TestLoadClass:
|
||||
"""Tests for the load_class utility function."""
|
||||
|
||||
def test_load_builtin_class(self):
|
||||
"""Test loading a builtin class."""
|
||||
list_class = load_class("builtins.list")
|
||||
assert list_class is list, "Should load the builtin list class"
|
||||
|
||||
def test_load_module(self):
|
||||
"""Test loading a module."""
|
||||
path_module = load_class("os.path")
|
||||
import os.path
|
||||
|
||||
assert path_module is os.path, "Should load os.path module"
|
||||
|
||||
def test_load_function(self):
|
||||
"""Test loading a function from a module."""
|
||||
makedirs_func = load_class("os.makedirs")
|
||||
assert makedirs_func is os.makedirs, "Should load os.makedirs function"
|
||||
|
||||
def test_load_standard_library_class(self):
|
||||
"""Test loading a standard library class."""
|
||||
temp_dir_class = load_class("tempfile.TemporaryDirectory")
|
||||
assert (
|
||||
temp_dir_class is tempfile.TemporaryDirectory
|
||||
), "Should load TemporaryDirectory class"
|
||||
|
||||
def test_load_nested_module_class(self):
|
||||
"""Test loading a class from a nested module."""
|
||||
datetime_class = load_class("datetime.datetime")
|
||||
import datetime
|
||||
|
||||
assert (
|
||||
datetime_class is datetime.datetime
|
||||
), "Should load datetime.datetime class"
|
||||
|
||||
def test_invalid_path_error(self):
|
||||
"""Test error handling for invalid paths."""
|
||||
with pytest.raises(ValueError, match="valid path like mymodule.provider_class"):
|
||||
load_class("invalid")
|
||||
|
||||
def test_nonexistent_module_error(self):
|
||||
"""Test error handling for nonexistent modules."""
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
load_class("nonexistent_module.SomeClass")
|
||||
|
||||
def test_nonexistent_attribute_error(self):
|
||||
"""Test error handling for nonexistent attributes."""
|
||||
with pytest.raises(AttributeError):
|
||||
load_class("os.NonexistentClass")
|
||||
|
||||
|
||||
class TestGetSystemMemory:
|
||||
"""Tests for the get_system_memory utility function."""
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v1_with_low_limit(self):
|
||||
"""Test cgroups v1 with low memory limit."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
|
||||
memory_limit_file.write("1073741824") # 1GB
|
||||
memory_limit_file.flush()
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename=memory_limit_file.name,
|
||||
memory_limit_filename_v2="__does_not_exist__",
|
||||
)
|
||||
assert memory == 1073741824, "Should return cgroup limit when low"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v1_with_high_limit(self):
|
||||
"""Test cgroups v1 with high memory limit (should fallback to psutil)."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
|
||||
memory_limit_file.write(str(2**63)) # Very high limit
|
||||
memory_limit_file.flush()
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename=memory_limit_file.name,
|
||||
memory_limit_filename_v2="__does_not_exist__",
|
||||
)
|
||||
assert (
|
||||
memory == psutil_memory
|
||||
), "Should fallback to psutil when cgroup limit is very high"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v2_with_limit(self):
|
||||
"""Test cgroups v2 with memory limit set."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_max_file:
|
||||
memory_max_file.write("2147483648\n") # 2GB with newline
|
||||
memory_max_file.flush()
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2=memory_max_file.name,
|
||||
)
|
||||
assert memory == 2147483648, "Should return cgroups v2 limit"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v2_unlimited(self):
|
||||
"""Test cgroups v2 with unlimited memory (max)."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_max_file:
|
||||
memory_max_file.write("max")
|
||||
memory_max_file.flush()
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2=memory_max_file.name,
|
||||
)
|
||||
assert (
|
||||
memory == psutil_memory
|
||||
), "Should fallback to psutil when cgroups v2 is unlimited"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_no_cgroup_files(self):
|
||||
"""Test fallback to psutil when no cgroup files exist."""
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2="__also_does_not_exist__",
|
||||
)
|
||||
assert memory == psutil_memory, "Should use psutil when no cgroup files exist"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
|
||||
|
||||
|
||||
class TestWaitForCondition:
|
||||
"""Tests for the synchronous wait_for_condition function."""
|
||||
|
||||
def test_immediate_true_condition(self):
|
||||
"""Test that function returns immediately when condition is already true."""
|
||||
|
||||
def always_true():
|
||||
return True
|
||||
|
||||
wait_for_condition(always_true, timeout=5)
|
||||
|
||||
def test_condition_becomes_true(self):
|
||||
"""Test waiting for a condition that becomes true after some time."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 3
|
||||
|
||||
wait_for_condition(condition, timeout=5, retry_interval_ms=50)
|
||||
|
||||
assert counter["value"] >= 3
|
||||
|
||||
def test_timeout_raises_runtime_error(self):
|
||||
"""Test that timeout raises RuntimeError with appropriate message."""
|
||||
|
||||
def always_false():
|
||||
return False
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
wait_for_condition(always_false, timeout=0.2, retry_interval_ms=50)
|
||||
|
||||
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
|
||||
|
||||
def test_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to the condition predictor."""
|
||||
|
||||
def condition_with_args(target, current=0):
|
||||
return current >= target
|
||||
|
||||
wait_for_condition(condition_with_args, timeout=1, target=5, current=10)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
def test_exception_handling_default(self):
|
||||
"""Test that exceptions are caught by default and timeout occurs."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
wait_for_condition(failing_condition, timeout=0.2, retry_interval_ms=50)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
assert "ValueError: Test exception" in error_msg
|
||||
|
||||
def test_exception_handling_raise_true(self):
|
||||
"""Test that exceptions are raised when raise_exceptions=True."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
wait_for_condition(failing_condition, timeout=1, raise_exceptions=True)
|
||||
|
||||
assert "Test exception" in str(exc_info.value)
|
||||
|
||||
def test_custom_retry_interval(self):
|
||||
"""Test that custom retry intervals are respected."""
|
||||
call_times = []
|
||||
|
||||
def condition():
|
||||
call_times.append(time.time())
|
||||
return len(call_times) >= 3
|
||||
|
||||
wait_for_condition(condition, timeout=5, retry_interval_ms=200)
|
||||
|
||||
# Verify that calls were spaced approximately 200ms apart
|
||||
if len(call_times) >= 2:
|
||||
interval = call_times[1] - call_times[0]
|
||||
assert 0.15 <= interval <= 0.25 # Allow some tolerance
|
||||
|
||||
def test_condition_with_mixed_results(self):
|
||||
"""Test condition that fails initially then succeeds."""
|
||||
attempts = {"count": 0}
|
||||
|
||||
def intermittent_condition():
|
||||
attempts["count"] += 1
|
||||
# Succeed on the 4th attempt
|
||||
return attempts["count"] >= 4
|
||||
|
||||
wait_for_condition(intermittent_condition, timeout=2, retry_interval_ms=100)
|
||||
assert attempts["count"] >= 4
|
||||
|
||||
|
||||
class TestAsyncWaitForCondition:
|
||||
"""Tests for the asynchronous async_wait_for_condition function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immediate_true_condition(self):
|
||||
"""Test that function returns immediately when condition is already true."""
|
||||
|
||||
def always_true():
|
||||
return True
|
||||
|
||||
await async_wait_for_condition(always_true, timeout=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_condition_becomes_true(self):
|
||||
"""Test waiting for an async condition that becomes true after some time."""
|
||||
counter = {"value": 0}
|
||||
|
||||
async def async_condition():
|
||||
counter["value"] += 1
|
||||
await asyncio.sleep(0.01) # Small async operation
|
||||
return counter["value"] >= 3
|
||||
|
||||
await async_wait_for_condition(async_condition, timeout=5, retry_interval_ms=50)
|
||||
|
||||
assert counter["value"] >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_condition_becomes_true(self):
|
||||
"""Test waiting for a sync condition in async context."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def sync_condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 3
|
||||
|
||||
await async_wait_for_condition(sync_condition, timeout=5, retry_interval_ms=50)
|
||||
assert counter["value"] >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_raises_runtime_error(self):
|
||||
"""Test that timeout raises RuntimeError with appropriate message."""
|
||||
|
||||
def always_false():
|
||||
return False
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
always_false, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to the condition predictor."""
|
||||
|
||||
def condition_with_args(target, current=0):
|
||||
return current >= target
|
||||
|
||||
await async_wait_for_condition(
|
||||
condition_with_args, timeout=1, target=5, current=10
|
||||
)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to an async condition predictor."""
|
||||
|
||||
async def async_condition_with_args(target, current=0):
|
||||
await asyncio.sleep(0.01)
|
||||
return current >= target
|
||||
|
||||
await async_wait_for_condition(
|
||||
async_condition_with_args, timeout=1, target=5, current=10
|
||||
)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_handling(self):
|
||||
"""Test that exceptions are caught and timeout occurs."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
failing_condition, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception_handling(self):
|
||||
"""Test that exceptions from async conditions are caught."""
|
||||
|
||||
async def async_failing_condition():
|
||||
await asyncio.sleep(0.01)
|
||||
raise ValueError("Async test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
async_failing_condition, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_retry_interval(self):
|
||||
"""Test that custom retry intervals are respected."""
|
||||
call_times = []
|
||||
|
||||
def condition():
|
||||
call_times.append(time.time())
|
||||
return len(call_times) >= 3
|
||||
|
||||
await async_wait_for_condition(condition, timeout=5, retry_interval_ms=200)
|
||||
|
||||
# Verify that calls were spaced approximately 200ms apart
|
||||
if len(call_times) >= 2:
|
||||
interval = call_times[1] - call_times[0]
|
||||
assert 0.15 <= interval <= 0.25 # Allow some tolerance
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_sync_async_conditions(self):
|
||||
"""Test that both sync and async conditions work in the same test."""
|
||||
sync_counter = {"value": 0}
|
||||
async_counter = {"value": 0}
|
||||
|
||||
def sync_condition():
|
||||
sync_counter["value"] += 1
|
||||
return sync_counter["value"] >= 2
|
||||
|
||||
async def async_condition():
|
||||
async_counter["value"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return async_counter["value"] >= 2
|
||||
|
||||
# Test sync condition
|
||||
await async_wait_for_condition(sync_condition, timeout=2, retry_interval_ms=50)
|
||||
assert sync_counter["value"] >= 2
|
||||
|
||||
# Test async condition
|
||||
await async_wait_for_condition(async_condition, timeout=2, retry_interval_ms=50)
|
||||
assert async_counter["value"] >= 2
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_zero_timeout(self):
|
||||
"""Test behavior with zero timeout."""
|
||||
|
||||
def slow_condition():
|
||||
time.sleep(0.1)
|
||||
return True
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
wait_for_condition(slow_condition, timeout=0, retry_interval_ms=50)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_zero_timeout(self):
|
||||
"""Test async behavior with zero timeout."""
|
||||
|
||||
async def slow_condition():
|
||||
await asyncio.sleep(0.1)
|
||||
return True
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await async_wait_for_condition(
|
||||
slow_condition, timeout=0, retry_interval_ms=50
|
||||
)
|
||||
|
||||
def test_very_small_retry_interval(self):
|
||||
"""Test with very small retry interval."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 5
|
||||
|
||||
start_time = time.time()
|
||||
wait_for_condition(condition, timeout=1, retry_interval_ms=1)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should complete quickly due to small retry interval
|
||||
assert elapsed < 0.5
|
||||
assert counter["value"] >= 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_very_small_retry_interval(self):
|
||||
"""Test async version with very small retry interval."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 5
|
||||
|
||||
start_time = time.time()
|
||||
await async_wait_for_condition(condition, timeout=1, retry_interval_ms=1)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should complete quickly due to small retry interval
|
||||
assert elapsed < 0.5
|
||||
assert counter["value"] >= 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""TLS utilities shared across Ray libraries (e.g. Serve)."""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import socket
|
||||
from typing import Tuple
|
||||
|
||||
from ray._common.network_utils import (
|
||||
get_localhost_ip,
|
||||
node_ip_address_from_perspective,
|
||||
)
|
||||
|
||||
|
||||
def generate_self_signed_tls_certs() -> Tuple[str, str]:
|
||||
"""Create self-signed key/cert pair for testing.
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_pem_contents, key_pem_contents).
|
||||
|
||||
Raises:
|
||||
ImportError: If the ``cryptography`` library is not installed.
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Using self-signed TLS certs requires `cryptography`. "
|
||||
"Install it with: pip install cryptography"
|
||||
) from e
|
||||
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048, backend=default_backend()
|
||||
)
|
||||
key_contents = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "ray-internal")])
|
||||
altnames = x509.SubjectAlternativeName(
|
||||
[
|
||||
x509.DNSName(socket.gethostbyname(socket.gethostname())),
|
||||
x509.DNSName(get_localhost_ip()),
|
||||
x509.DNSName(node_ip_address_from_perspective()),
|
||||
x509.DNSName("localhost"),
|
||||
]
|
||||
)
|
||||
now = datetime.datetime.utcnow()
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.add_extension(altnames, critical=False)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(key, hashes.SHA256(), default_backend())
|
||||
)
|
||||
|
||||
cert_contents = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return cert_contents, key_contents
|
||||
|
||||
|
||||
def add_port_to_grpc_server(server, address):
|
||||
import grpc
|
||||
|
||||
if os.environ.get("RAY_USE_TLS", "0").lower() in ("1", "true"):
|
||||
server_cert_chain, private_key, ca_cert = load_certs_from_env()
|
||||
credentials = grpc.ssl_server_credentials(
|
||||
[(private_key, server_cert_chain)],
|
||||
root_certificates=ca_cert,
|
||||
require_client_auth=ca_cert is not None,
|
||||
)
|
||||
return server.add_secure_port(address, credentials)
|
||||
else:
|
||||
return server.add_insecure_port(address)
|
||||
|
||||
|
||||
def load_certs_from_env():
|
||||
tls_env_vars = ["RAY_TLS_SERVER_CERT", "RAY_TLS_SERVER_KEY", "RAY_TLS_CA_CERT"]
|
||||
if any(v not in os.environ for v in tls_env_vars):
|
||||
raise RuntimeError(
|
||||
"If the environment variable RAY_USE_TLS is set to true "
|
||||
"then RAY_TLS_SERVER_CERT, RAY_TLS_SERVER_KEY and "
|
||||
"RAY_TLS_CA_CERT must also be set."
|
||||
)
|
||||
|
||||
with open(os.environ["RAY_TLS_SERVER_CERT"], "rb") as f:
|
||||
server_cert_chain = f.read()
|
||||
with open(os.environ["RAY_TLS_SERVER_KEY"], "rb") as f:
|
||||
private_key = f.read()
|
||||
with open(os.environ["RAY_TLS_CA_CERT"], "rb") as f:
|
||||
ca_cert = f.read()
|
||||
|
||||
return server_cert_chain, private_key, ca_cert
|
||||
@@ -0,0 +1,63 @@
|
||||
SCHEMA_VERSION = "0.1"
|
||||
|
||||
# The key to store / obtain cluster metadata.
|
||||
CLUSTER_METADATA_KEY = b"CLUSTER_METADATA"
|
||||
|
||||
# The name of a json file where usage stats will be written.
|
||||
USAGE_STATS_FILE = "usage_stats.json"
|
||||
|
||||
USAGE_STATS_ENABLED_ENV_VAR = "RAY_USAGE_STATS_ENABLED"
|
||||
|
||||
USAGE_STATS_SOURCE_ENV_VAR = "RAY_USAGE_STATS_SOURCE"
|
||||
|
||||
USAGE_STATS_SOURCE_OSS = "OSS"
|
||||
|
||||
USAGE_STATS_ENABLED_FOR_CLI_MESSAGE = (
|
||||
"Usage stats collection is enabled. To disable this, add `--disable-usage-stats` "
|
||||
"to the command that starts the cluster, or run the following command:"
|
||||
" `ray disable-usage-stats` before starting the cluster. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_ENABLED_FOR_RAY_INIT_MESSAGE = (
|
||||
"Usage stats collection is enabled. To disable this, run the following command:"
|
||||
" `ray disable-usage-stats` before starting Ray. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_DISABLED_MESSAGE = "Usage stats collection is disabled."
|
||||
|
||||
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_CLI_MESSAGE = (
|
||||
"Usage stats collection is enabled by default without user confirmation "
|
||||
"because this terminal is detected to be non-interactive. "
|
||||
"To disable this, add `--disable-usage-stats` to the command that starts "
|
||||
"the cluster, or run the following command:"
|
||||
" `ray disable-usage-stats` before starting the cluster. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_RAY_INIT_MESSAGE = (
|
||||
"Usage stats collection is enabled by default for nightly wheels. "
|
||||
"To disable this, run the following command:"
|
||||
" `ray disable-usage-stats` before starting Ray. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_CONFIRMATION_MESSAGE = (
|
||||
"Enable usage stats collection? "
|
||||
"This prompt will auto-proceed in 10 seconds to avoid blocking cluster startup."
|
||||
)
|
||||
|
||||
LIBRARY_USAGE_SET_NAME = "library_usage_"
|
||||
|
||||
HARDWARE_USAGE_SET_NAME = "hardware_usage_"
|
||||
|
||||
# Keep in-sync with the same constants defined in usage_stats_client.h
|
||||
EXTRA_USAGE_TAG_PREFIX = "extra_usage_tag_"
|
||||
USAGE_STATS_NAMESPACE = "usage_stats"
|
||||
|
||||
KUBERNETES_SERVICE_HOST_ENV = "KUBERNETES_SERVICE_HOST"
|
||||
KUBERAY_ENV = "RAY_USAGE_STATS_KUBERAY_IN_USE"
|
||||
|
||||
PROVIDER_KUBERNETES_GENERIC = "kubernetes"
|
||||
PROVIDER_KUBERAY = "kuberay"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import errno
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from inspect import signature
|
||||
from types import ModuleType
|
||||
from typing import Any, Coroutine, Dict, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray._raylet import GcsClient, NodeID
|
||||
from ray.core.generated.gcs_pb2 import GcsNodeInfo
|
||||
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def env_integer(key, default):
|
||||
if key in os.environ:
|
||||
value = os.environ[key]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f"Found {key} in environment, but value must "
|
||||
f"be an integer. Got: {value}. Returning "
|
||||
f"provided default {default}."
|
||||
)
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def env_float(key, default):
|
||||
if key in os.environ:
|
||||
value = os.environ[key]
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f"Found {key} in environment, but value must "
|
||||
f"be a float. Got: {value}. Returning "
|
||||
f"provided default {default}."
|
||||
)
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def env_bool(key, default):
|
||||
if key in os.environ:
|
||||
val = os.environ[key].lower()
|
||||
return val == "true" or val == "1"
|
||||
return default
|
||||
|
||||
|
||||
def import_module_and_attr(
|
||||
full_path: str, *, reload_module: bool = False
|
||||
) -> Tuple[ModuleType, Any]:
|
||||
"""Given a full import path to a module attr, return the imported module and attr.
|
||||
|
||||
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
|
||||
|
||||
Args:
|
||||
full_path: The full import path to the module and attr.
|
||||
reload_module: Whether to reload the module.
|
||||
|
||||
Returns:
|
||||
A tuple of the imported module and attr.
|
||||
"""
|
||||
if ":" in full_path:
|
||||
if full_path.count(":") > 1:
|
||||
raise ValueError(
|
||||
f'Got invalid import path "{full_path}". An '
|
||||
"import path may have at most one colon."
|
||||
)
|
||||
module_name, attr_name = full_path.split(":")
|
||||
else:
|
||||
last_period_idx = full_path.rfind(".")
|
||||
module_name = full_path[:last_period_idx]
|
||||
attr_name = full_path[last_period_idx + 1 :]
|
||||
module = importlib.import_module(module_name)
|
||||
if reload_module:
|
||||
importlib.reload(module)
|
||||
return module, getattr(module, attr_name)
|
||||
|
||||
|
||||
def import_attr(full_path: str, *, reload_module: bool = False) -> Any:
|
||||
"""Given a full import path to a module attr, return the imported attr.
|
||||
|
||||
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
|
||||
|
||||
For example, the following are equivalent:
|
||||
MyClass = import_attr("module.submodule:MyClass")
|
||||
MyClass = import_attr("module.submodule.MyClass")
|
||||
from module.submodule import MyClass
|
||||
|
||||
Args:
|
||||
full_path: The full import path to the module and attr.
|
||||
reload_module: Whether to reload the module.
|
||||
|
||||
Returns:
|
||||
Imported attr
|
||||
"""
|
||||
return import_module_and_attr(full_path, reload_module=reload_module)[1]
|
||||
|
||||
|
||||
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Get a running async event loop if one exists, otherwise create one.
|
||||
|
||||
This function serves as a proxy for the deprecating get_event_loop().
|
||||
It tries to get the running loop first, and if no running loop
|
||||
could be retrieved:
|
||||
- For python version <3.10: it falls back to the get_event_loop
|
||||
call.
|
||||
- For python version >= 3.10: it uses the same python implementation
|
||||
of _get_event_loop() at asyncio/events.py.
|
||||
|
||||
Ideally, one should use high level APIs like asyncio.run() with python
|
||||
version >= 3.7, if not possible, one should create and manage the event
|
||||
loops explicitly.
|
||||
"""
|
||||
vers_info = sys.version_info
|
||||
if vers_info.major >= 3 and vers_info.minor >= 10:
|
||||
# This follows the implementation of the deprecating `get_event_loop`
|
||||
# in python3.10's asyncio. See python3.10/asyncio/events.py
|
||||
# _get_event_loop()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
assert loop is not None
|
||||
return loop
|
||||
except RuntimeError as e:
|
||||
# No running loop, relying on the error message as for now to
|
||||
# differentiate runtime errors.
|
||||
assert "no running event loop" in str(e)
|
||||
try:
|
||||
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||
return loop
|
||||
except RuntimeError:
|
||||
# Python 3.14+: get_event_loop() no longer creates a loop automatically
|
||||
# See: https://docs.python.org/3.14/library/asyncio-eventloop.html
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop
|
||||
|
||||
return asyncio.get_event_loop()
|
||||
|
||||
|
||||
_BACKGROUND_TASKS = set()
|
||||
|
||||
|
||||
def run_background_task(coroutine: Coroutine) -> asyncio.Task:
|
||||
"""Schedule a task reliably to the event loop.
|
||||
|
||||
This API is used when you don't want to cache the reference of `asyncio.Task`.
|
||||
For example,
|
||||
|
||||
```
|
||||
get_event_loop().create_task(coroutine(*args))
|
||||
```
|
||||
|
||||
The above code doesn't guarantee to schedule the coroutine to the event loops
|
||||
|
||||
When using create_task in a "fire and forget" way, we should keep the references
|
||||
alive for the reliable execution. This API is used to fire and forget
|
||||
asynchronous execution.
|
||||
|
||||
https://docs.python.org/3/library/asyncio-task.html#creating-tasks
|
||||
"""
|
||||
task = get_or_create_event_loop().create_task(coroutine)
|
||||
# Add task to the set. This creates a strong reference.
|
||||
_BACKGROUND_TASKS.add(task)
|
||||
|
||||
# To prevent keeping references to finished tasks forever,
|
||||
# make each task remove its own reference from the set after
|
||||
# completion:
|
||||
task.add_done_callback(_BACKGROUND_TASKS.discard)
|
||||
return task
|
||||
|
||||
|
||||
# Used in gpu detection
|
||||
RESOURCE_CONSTRAINT_PREFIX = "accelerator_type:"
|
||||
PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME = "bundle"
|
||||
|
||||
|
||||
def resources_from_ray_options(options_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Determine a task's resource requirements.
|
||||
|
||||
Args:
|
||||
options_dict: The dictionary that contains resources requirements.
|
||||
|
||||
Returns:
|
||||
A dictionary of the resource requirements for the task.
|
||||
"""
|
||||
resources = (options_dict.get("resources") or {}).copy()
|
||||
|
||||
if "CPU" in resources or "GPU" in resources:
|
||||
raise ValueError(
|
||||
"The resources dictionary must not contain the key 'CPU' or 'GPU'"
|
||||
)
|
||||
elif "memory" in resources or "object_store_memory" in resources:
|
||||
raise ValueError(
|
||||
"The resources dictionary must not "
|
||||
"contain the key 'memory' or 'object_store_memory'"
|
||||
)
|
||||
elif PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME in resources:
|
||||
raise ValueError(
|
||||
"The resource should not include `bundle` which "
|
||||
f"is reserved for Ray. resources: {resources}"
|
||||
)
|
||||
|
||||
num_cpus = options_dict.get("num_cpus")
|
||||
num_gpus = options_dict.get("num_gpus")
|
||||
memory = options_dict.get("memory")
|
||||
object_store_memory = options_dict.get("object_store_memory")
|
||||
accelerator_type = options_dict.get("accelerator_type")
|
||||
|
||||
if num_cpus is not None:
|
||||
resources["CPU"] = num_cpus
|
||||
if num_gpus is not None:
|
||||
resources["GPU"] = num_gpus
|
||||
if memory is not None:
|
||||
resources["memory"] = int(memory)
|
||||
if object_store_memory is not None:
|
||||
resources["object_store_memory"] = object_store_memory
|
||||
if accelerator_type is not None:
|
||||
resources[f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"] = 0.001
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# Match the standard alphabet used for UUIDs.
|
||||
RANDOM_STRING_ALPHABET = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
def get_random_alphanumeric_string(length: int):
|
||||
"""Generates random string of length consisting exclusively of
|
||||
- Lower-case ASCII chars
|
||||
- Digits
|
||||
"""
|
||||
return "".join(random.choices(RANDOM_STRING_ALPHABET, k=length))
|
||||
|
||||
|
||||
_PRINTED_WARNING = set()
|
||||
|
||||
|
||||
def get_call_location(back: int = 1):
|
||||
"""
|
||||
Get the location (filename and line number) of a function caller, `back`
|
||||
frames up the stack.
|
||||
|
||||
Args:
|
||||
back: The number of frames to go up the stack, not including this
|
||||
function.
|
||||
|
||||
Returns:
|
||||
A string with the filename and line number of the caller.
|
||||
For example, "myfile.py:123".
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
try:
|
||||
frame = stack[back + 1]
|
||||
return f"{frame.filename}:{frame.lineno}"
|
||||
except IndexError:
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def resolve_user_ray_temp_dir(gcs_client: GcsClient, node_id: str):
|
||||
"""
|
||||
Get the ray temp directory.
|
||||
|
||||
If a temp dir was specified for this node, this function will
|
||||
retrieve the information from GCS. Otherwise, it will fallback to the
|
||||
default ray temp directory.
|
||||
|
||||
Args:
|
||||
gcs_client: The GCS client.
|
||||
node_id: The ID of the node to fetch the temp dir for.
|
||||
E.g.: "1a9904d8aa3de65367830e2aef6313a5b2e9d4b0e3725e0dceeacb1b"
|
||||
(hex string representation of the node ID)
|
||||
|
||||
Returns:
|
||||
The path to the ray temp directory.
|
||||
"""
|
||||
# check if temp dir is available from runtime context
|
||||
if ray.is_initialized() and ray.get_runtime_context().get_node_id() == node_id:
|
||||
return ray.get_runtime_context().get_temp_dir()
|
||||
|
||||
# Fetch temp dir as specified by --temp-dir at creation time.
|
||||
try:
|
||||
# Create node selector for node_id filter
|
||||
node_selector = GetAllNodeInfoRequest.NodeSelector()
|
||||
node_selector.node_id = NodeID.from_hex(node_id).binary()
|
||||
|
||||
node_infos = gcs_client.get_all_node_info(
|
||||
node_selectors=[node_selector],
|
||||
state_filter=GcsNodeInfo.GcsNodeState.ALIVE,
|
||||
).values()
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Failed to get node info from GCS when fetching tempdir for node {node_id}: {e}"
|
||||
)
|
||||
if not node_infos:
|
||||
raise Exception(
|
||||
f"No node info associated with ALIVE state found for node {node_id} in GCS"
|
||||
)
|
||||
|
||||
node_info = next(iter(node_infos))
|
||||
if node_info is not None:
|
||||
temp_dir = getattr(node_info, "temp_dir", None)
|
||||
if temp_dir is not None:
|
||||
return temp_dir
|
||||
else:
|
||||
raise Exception(
|
||||
"Node temp_dir was not found in NodeInfo. did the node's raylet start successfully?"
|
||||
)
|
||||
|
||||
|
||||
def get_default_system_temp_dir():
|
||||
if "RAY_TMPDIR" in os.environ:
|
||||
return os.environ["RAY_TMPDIR"]
|
||||
elif sys.platform.startswith("linux") and "TMPDIR" in os.environ:
|
||||
return os.environ["TMPDIR"]
|
||||
elif sys.platform.startswith("darwin") or sys.platform.startswith("linux"):
|
||||
# Ideally we wouldn't need this fallback, but keep it for now for
|
||||
# for compatibility
|
||||
tempdir = os.path.join(os.sep, "tmp")
|
||||
else:
|
||||
tempdir = tempfile.gettempdir()
|
||||
|
||||
return tempdir
|
||||
|
||||
|
||||
def get_default_ray_temp_dir():
|
||||
return os.path.join(get_default_system_temp_dir(), "ray")
|
||||
|
||||
|
||||
def get_ray_address_file(temp_dir: Optional[str]):
|
||||
if temp_dir is None:
|
||||
temp_dir = get_default_ray_temp_dir()
|
||||
return os.path.join(temp_dir, "ray_current_cluster")
|
||||
|
||||
|
||||
def reset_ray_address(temp_dir: Optional[str] = None):
|
||||
address_file = get_ray_address_file(temp_dir)
|
||||
if os.path.exists(address_file):
|
||||
try:
|
||||
os.remove(address_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_class(path):
|
||||
"""Load a class at runtime given a full path.
|
||||
|
||||
Example of the path: mypkg.mysubpkg.myclass
|
||||
"""
|
||||
class_data = path.split(".")
|
||||
if len(class_data) < 2:
|
||||
raise ValueError("You need to pass a valid path like mymodule.provider_class")
|
||||
module_path = ".".join(class_data[:-1])
|
||||
class_str = class_data[-1]
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, class_str)
|
||||
|
||||
|
||||
def get_system_memory(
|
||||
# For cgroups v1:
|
||||
memory_limit_filename: str = "/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
# For cgroups v2:
|
||||
memory_limit_filename_v2: str = "/sys/fs/cgroup/memory.max",
|
||||
):
|
||||
"""Return the total amount of system memory in bytes.
|
||||
|
||||
Args:
|
||||
memory_limit_filename: The path to the file that contains the memory
|
||||
limit for the Docker container. Defaults to
|
||||
/sys/fs/cgroup/memory/memory.limit_in_bytes.
|
||||
memory_limit_filename_v2: The path to the file that contains the memory
|
||||
limit for the Docker container in cgroups v2. Defaults to
|
||||
/sys/fs/cgroup/memory.max.
|
||||
|
||||
Returns:
|
||||
The total amount of system memory in bytes.
|
||||
"""
|
||||
# Try to accurately figure out the memory limit if we are in a docker
|
||||
# container. Note that this file is not specific to Docker and its value is
|
||||
# often much larger than the actual amount of memory.
|
||||
docker_limit = None
|
||||
if os.path.exists(memory_limit_filename):
|
||||
with open(memory_limit_filename, "r") as f:
|
||||
docker_limit = int(f.read().strip())
|
||||
elif os.path.exists(memory_limit_filename_v2):
|
||||
with open(memory_limit_filename_v2, "r") as f:
|
||||
# Don't forget to strip() the newline:
|
||||
max_file = f.read().strip()
|
||||
if max_file.isnumeric():
|
||||
docker_limit = int(max_file)
|
||||
else:
|
||||
# max_file is "max", i.e. is unset.
|
||||
docker_limit = None
|
||||
|
||||
# Use psutil if it is available.
|
||||
psutil_memory_in_bytes = psutil.virtual_memory().total
|
||||
|
||||
if docker_limit is not None:
|
||||
# We take the min because the cgroup limit is very large if we aren't
|
||||
# in Docker.
|
||||
return min(docker_limit, psutil_memory_in_bytes)
|
||||
|
||||
return psutil_memory_in_bytes
|
||||
|
||||
|
||||
def binary_to_hex(identifier):
|
||||
hex_identifier = binascii.hexlify(identifier)
|
||||
hex_identifier = hex_identifier.decode()
|
||||
return hex_identifier
|
||||
|
||||
|
||||
def hex_to_binary(hex_identifier):
|
||||
return binascii.unhexlify(hex_identifier)
|
||||
|
||||
|
||||
def try_make_directory_shared(directory_path):
|
||||
try:
|
||||
os.chmod(directory_path, 0o0777)
|
||||
except OSError as e:
|
||||
# Silently suppress the PermissionError that is thrown by the chmod.
|
||||
# This is done because the user attempting to change the permissions
|
||||
# on a directory may not own it. The chmod is attempted whether the
|
||||
# directory is new or not to avoid race conditions.
|
||||
# ray-project/ray/#3591
|
||||
if e.errno in [errno.EACCES, errno.EPERM]:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def try_to_create_directory(directory_path):
|
||||
# Attempt to create a directory that is globally readable/writable.
|
||||
directory_path = os.path.expanduser(directory_path)
|
||||
os.makedirs(directory_path, exist_ok=True)
|
||||
# Change the log directory permissions so others can use it. This is
|
||||
# important when multiple people are using the same machine.
|
||||
try_make_directory_shared(directory_path)
|
||||
|
||||
|
||||
def get_function_args(callable):
|
||||
all_parameters = frozenset(signature(callable).parameters)
|
||||
return list(all_parameters)
|
||||
|
||||
|
||||
def decode(byte_str: str, allow_none: bool = False, encode_type: str = "utf-8"):
|
||||
"""Make this unicode in Python 3, otherwise leave it as bytes.
|
||||
|
||||
Args:
|
||||
byte_str: The byte string to decode.
|
||||
allow_none: If true, then we will allow byte_str to be None in which
|
||||
case we will return an empty string. TODO(rkn): Remove this flag.
|
||||
This is only here to simplify upgrading to flatbuffers 1.10.0.
|
||||
encode_type: The encoding type to use for decoding. Defaults to "utf-8".
|
||||
|
||||
Returns:
|
||||
A byte string in Python 2 and a unicode string in Python 3.
|
||||
"""
|
||||
if byte_str is None and allow_none:
|
||||
return ""
|
||||
|
||||
if not isinstance(byte_str, bytes):
|
||||
raise ValueError(f"The argument {byte_str} must be a bytes object.")
|
||||
return byte_str.decode(encode_type)
|
||||
|
||||
|
||||
class TimerBase(ABC):
|
||||
@abstractmethod
|
||||
def time(self) -> float:
|
||||
"""Return the current time."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Timer(TimerBase):
|
||||
def time(self) -> float:
|
||||
return time.time()
|
||||
@@ -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__"],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)}"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 isn’t 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}")
|
||||
@@ -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
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
@@ -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__}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user