Files
ray-project--ray/python/ray/_common/ray_option_utils.py
T
2026-07-13 13:17:40 +08:00

455 lines
17 KiB
Python

"""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}