Files
ray-project--ray/python/ray/train/v2/_internal/util.py
T
2026-07-13 13:17:40 +08:00

370 lines
11 KiB
Python

import asyncio
import contextlib
import functools
import logging
import threading
import time
import traceback
from datetime import datetime
from enum import Enum
from typing import (
Any,
Callable,
ContextManager,
Dict,
Generator,
Generic,
Iterator,
List,
Optional,
TypeVar,
Union,
)
import ray
from ray.train._internal.utils import count_required_parameters
from ray.train.v2._internal.exceptions import UserExceptionWithTraceback
logger = logging.getLogger(__name__)
T = TypeVar("T")
def bundle_to_remote_args(bundle: dict) -> dict:
"""Convert a bundle of resources to Ray actor/task arguments.
>>> bundle_to_remote_args({"GPU": 1, "memory": 1, "custom": 0.1})
{'num_cpus': 0, 'num_gpus': 1, 'memory': 1, 'resources': {'custom': 0.1}}
"""
bundle = bundle.copy()
args = {
"num_cpus": bundle.pop("CPU", 0),
"num_gpus": bundle.pop("GPU", 0),
"memory": bundle.pop("memory", 0),
}
if bundle:
args["resources"] = bundle
return args
def construct_train_func(
train_func: Union[Callable[[], T], Callable[[Dict[str, Any]], T]],
config: Optional[Dict[str, Any]],
train_func_context: ContextManager,
fn_arg_name: Optional[str] = "train_loop_per_worker",
) -> Callable[[], T]:
"""Validates and constructs the training function to execute.
Args:
train_func: The training function to execute.
This can either take in no arguments or a ``config`` dict.
config: Configurations to pass into ``train_func``. If None then an
empty Dict will be created.
train_func_context: Context manager for user's `train_func`, which executes
backend-specific logic before and after the training function.
fn_arg_name: The name of training function to use for error messages.
Returns:
A valid training function.
Raises:
ValueError: if the input ``train_func`` is invalid.
"""
num_required_params = count_required_parameters(train_func)
if num_required_params > 1:
err_msg = (
f"{fn_arg_name} should take in 0 or 1 required arguments, but it accepts "
f"{num_required_params} required arguments instead."
)
raise ValueError(err_msg)
if num_required_params == 1:
config = config or {}
@functools.wraps(train_func)
def train_fn():
with train_func_context():
return train_func(config)
else: # num_params == 0
@functools.wraps(train_func)
def train_fn():
with train_func_context():
return train_func()
return train_fn
class TrainingFramework(Enum):
TORCH = "torch"
JAX = "jax"
TENSORFLOW = "tensorflow"
XGBOOST = "xgboost"
LIGHTGBM = "lightgbm"
def module_names(self) -> tuple[str, ...]:
"""Returns the relevant module names for the training framework.
These module names are used by Train state version collection (see
`_get_framework_version`) to gather versions of key framework-related packages.
Note: If adding a new module, make sure to use the module name rather than
the distribution name. (e.g. sklearn instead of scikit-learn)
"""
if self is TrainingFramework.TORCH:
return ("torch",)
if self is TrainingFramework.JAX:
return ("jax", "jaxlib")
if self is TrainingFramework.TENSORFLOW:
return ("tensorflow", "keras")
if self is TrainingFramework.XGBOOST:
return ("xgboost",)
if self is TrainingFramework.LIGHTGBM:
return ("lightgbm",)
return (self.value,)
class ObjectRefWrapper(Generic[T]):
"""Thin wrapper around ray.put to manually control dereferencing."""
def __init__(self, obj: T):
self._ref = ray.put(obj)
def get(self) -> T:
return ray.get(self._ref)
def date_str(include_ms: bool = False):
pattern = "%Y-%m-%d_%H-%M-%S"
if include_ms:
pattern += ".%f"
return datetime.today().strftime(pattern)
def time_monotonic():
return time.monotonic()
def _copy_doc(copy_func):
def wrapped(func):
func.__doc__ = copy_func.__doc__
return func
return wrapped
@contextlib.contextmanager
def invoke_context_managers(
context_managers: List[ContextManager],
) -> Generator[None, None, None]:
"""
Utility to invoke a list of context managers and yield sequentially.
Args:
context_managers: List of context managers to invoke.
"""
with contextlib.ExitStack() as stack:
for context_manager in context_managers:
stack.enter_context(context_manager())
yield
def get_module_name(obj: object) -> str:
"""Returns the full module name of the given object, including its qualified name.
Args:
obj: The object (class, function, etc.) whose module name is required.
Returns:
Full module and qualified name as a string.
"""
return f"{obj.__module__}.{obj.__qualname__}"
def get_callable_name(fn: Callable) -> str:
"""Returns a readable name for any callable.
Args:
fn: The callable to extract a name from.
Returns:
A human-readable name for the callable.
Examples:
>>> get_callable_name(lambda x: x)
'<lambda>'
>>> def foo(a, b): pass
>>> get_callable_name(foo)
'foo'
>>> from functools import partial
>>> bar = partial(partial(foo, a=1), b=2)
>>> get_callable_name(bar)
'foo'
>>> class Dummy:
... def __call__(self, a, b): pass
>>> get_callable_name(Dummy())
'Dummy'
"""
if isinstance(fn, functools.partial):
return get_callable_name(fn.func)
# Use __name__ for regular functions and lambdas
if hasattr(fn, "__name__"):
return fn.__name__
# Fallback to the class name for objects that implement __call__
return fn.__class__.__name__
def construct_user_exception_with_traceback(
e: BaseException, exclude_frames: int = 0
) -> UserExceptionWithTraceback:
"""Construct a UserExceptionWithTraceback from a base exception.
Args:
e: The base exception to construct a UserExceptionWithTraceback from.
exclude_frames: The number of frames to exclude from the beginnning of
the traceback.
Returns:
A UserExceptionWithTraceback object.
"""
# TODO(justinvyu): This is brittle and may break if the call stack
# changes. Figure out a more robust way to exclude these frames.
exc_traceback_str = traceback.format_exc(
limit=-(len(traceback.extract_tb(e.__traceback__)) - exclude_frames)
)
logger.error(f"Error in training function:\n{exc_traceback_str}")
return UserExceptionWithTraceback(e, traceback_str=exc_traceback_str)
def _in_ray_train_worker() -> bool:
"""Check if the current process is a Ray Train V2 worker."""
from ray.train.v2._internal.execution.train_fn_utils import get_train_fn_utils
try:
get_train_fn_utils()
return True
except RuntimeError:
return False
def requires_train_worker(raise_in_tune_session: bool = False) -> Callable:
"""Check that the caller is a Ray Train worker spawned by Ray Train,
with access to training function utilities.
Args:
raise_in_tune_session: Whether to raise a specific error message if the caller
is in a Tune session. If True, will raise a DeprecationWarning.
Returns:
A decorator that performs this check, which raises an error if the caller
is not a Ray Train worker.
"""
def _wrap(fn: Callable) -> Callable:
@functools.wraps(fn)
def _wrapped_fn(*args, **kwargs):
from ray.tune.trainable.trainable_fn_utils import _in_tune_session
if raise_in_tune_session and _in_tune_session():
raise DeprecationWarning(
f"`ray.train.{fn.__name__}` is deprecated when running in a function "
"passed to Ray Tune. Please use the equivalent `ray.tune` API instead. "
"See this issue for more context: "
"https://github.com/ray-project/ray/issues/49454"
)
if not _in_ray_train_worker():
raise RuntimeError(
f"`{fn.__name__}` cannot be used outside of a Ray Train training function. "
"You are calling this API from the driver or another non-training process. "
"These utilities are only available within a function launched by `trainer.fit()`."
)
return fn(*args, **kwargs)
return _wrapped_fn
return _wrap
async def wait_with_logging(
condition: asyncio.Condition,
predicate: Optional[Callable[[], bool]] = None,
generate_warning_message: Optional[Callable[[], str]] = None,
warn_interval_s: float = 60,
timeout_s: Optional[float] = None,
):
"""Waits for condition to be notified, logging warnings and eventually timing out.
You must acquire the condition before calling this function.
Args:
condition: The condition to wait for.
predicate: Wait until this predicate is True. If None, wait until the condition
is notified.
generate_warning_message: A function that generates the warning message to log.
If None, no warning is logged.
warn_interval_s: The interval in seconds to log a warning.
timeout_s: The timeout in seconds. Defaults to``None`` to not time out.
"""
async def _wait_loop():
while True:
try:
await asyncio.wait_for(
condition.wait()
if predicate is None
else condition.wait_for(predicate),
timeout=warn_interval_s,
)
return
# asyncio.wait_for() raises `asyncio.TimeoutError` for asyncio<=3.10
# and raises `TimeoutError` for asyncio>=3.11
# https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for
except (asyncio.TimeoutError, TimeoutError):
if generate_warning_message is not None:
warning_message = generate_warning_message()
logger.warning(warning_message)
await asyncio.wait_for(
_wait_loop(),
timeout=timeout_s,
)
@contextlib.contextmanager
def context_watchdog(fn: Callable, *args: Any) -> Iterator[None]:
"""Run a function in a background thread for the duration of the context.
The function is started in a daemon thread on entry. On exit, a
threading.Event is set to signal the thread to stop. The function is
responsible for checking the event and returning promptly once it is set.
Args:
fn: A function whose first argument is a threading.Event stop signal.
The function should return when stop_event.is_set() or
stop_event.wait(...) returns True.
*args: Additional arguments forwarded to fn after the stop event.
Yields:
None: Control is yielded to the caller while the watchdog thread runs.
"""
stop_event = threading.Event()
thread = threading.Thread(
target=fn,
args=(stop_event, *args),
daemon=True, # thread will end even if the finally is bypassed by an abnormal exit
)
thread.start()
try:
yield
finally:
stop_event.set()
thread.join()