Files
2026-07-13 13:17:40 +08:00

1124 lines
46 KiB
Python

import inspect
import json
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from google.protobuf.descriptor import FieldDescriptor
from google.protobuf.message import Message
from pydantic import (
BaseModel,
ConfigDict,
Field,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
field_validator,
model_validator,
)
from ray import cloudpickle
from ray._common import ray_option_utils
from ray._common.serialization import pickle_dumps
from ray._common.utils import resources_from_ray_options
from ray.serve._private.constants import (
DEFAULT_CONSTRUCTOR_RETRY_COUNT,
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S,
DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S,
DEFAULT_HEALTH_CHECK_PERIOD_S,
DEFAULT_HEALTH_CHECK_TIMEOUT_S,
DEFAULT_MAX_ONGOING_REQUESTS,
DEFAULT_ROLLING_UPDATE_PERCENTAGE,
MAX_REPLICAS_PER_NODE_MAX_VALUE,
)
from ray.serve._private.utils import DEFAULT, DeploymentOptionUpdateType
from ray.serve.config import (
AggregationFunction,
AutoscalingConfig,
DeploymentActorConfig,
GangPlacementStrategy,
GangRuntimeFailurePolicy,
GangSchedulingConfig,
RequestRouterConfig,
)
from ray.serve.generated.serve_pb2 import (
AutoscalingConfig as AutoscalingConfigProto,
DeploymentActorConfig as DeploymentActorConfigProto,
DeploymentConfig as DeploymentConfigProto,
DeploymentLanguage,
EncodingType as EncodingTypeProto,
GangPlacementStrategy as GangPlacementStrategyProto,
GangRuntimeFailurePolicy as GangRuntimeFailurePolicyProto,
GangSchedulingConfig as GangSchedulingConfigProto,
LoggingConfig as LoggingConfigProto,
ReplicaConfig as ReplicaConfigProto,
RequestRouterConfig as RequestRouterConfigProto,
)
from ray.util.placement_group import validate_placement_group
def _needs_pickle(deployment_language: DeploymentLanguage, is_cross_language: bool):
"""From Serve client API's perspective, decide whether pickling is needed."""
if deployment_language == DeploymentLanguage.PYTHON and not is_cross_language:
# Python client deploying Python replicas.
return True
elif deployment_language == DeploymentLanguage.JAVA and is_cross_language:
# Python client deploying Java replicas,
# using xlang serialization via cloudpickle.
return True
else:
return False
# protobuf>=7 removed the deprecated FieldDescriptor.label in favor of the
# is_repeated property; detect once at import and bind the right check.
if hasattr(FieldDescriptor, "is_repeated"):
def _field_is_repeated(field: FieldDescriptor) -> bool:
return bool(field.is_repeated)
else:
def _field_is_repeated(field: FieldDescriptor) -> bool:
return field.label == FieldDescriptor.LABEL_REPEATED
def _proto_to_dict(proto: Message) -> Dict:
"""Recursively convert a protobuf into a Python dictionary.
This is an alternative to protobuf's `MessageToDict`. Unlike
`MessageToDict`, this function doesn't add an extra base64
encoding to bytes when constructing a json response.
"""
data = {}
# Fill data with non-empty fields.
for field, value in proto.ListFields():
# Handle repeated fields
if _field_is_repeated(field):
# if we dont do this block the repeated field will be a list of
# `google.protobuf.internal.containers.RepeatedScalarFieldContainer
# Explicitly convert to list
if field.type == FieldDescriptor.TYPE_MESSAGE:
data[field.name] = [
_proto_to_dict(v) for v in value
] # Convert each item
else:
data[field.name] = list(value) # Convert to list directly
# Recursively call if the field is another protobuf.
elif field.type == FieldDescriptor.TYPE_MESSAGE:
data[field.name] = _proto_to_dict(value)
else:
data[field.name] = value
# Fill data default values.
for field in proto.DESCRIPTOR.fields:
if (
field.name not in data # skip the fields that are already set
and field.type != FieldDescriptor.TYPE_MESSAGE # skip nested messages
and not field.containing_oneof # skip optional fields
):
data[field.name] = field.default_value
return data
class DeploymentConfig(BaseModel):
"""Internal datastructure wrapping config options for a deployment.
Args:
num_replicas: The number of processes to start up that
handles requests to this deployment. Defaults to 1.
max_ongoing_requests: The maximum number of queries
that is sent to a replica of this deployment without receiving
a response. Defaults to 5.
max_queued_requests: Maximum number of requests to this deployment that will be
queued at each *caller* (proxy or DeploymentHandle). Once this limit is
reached, subsequent requests will raise a BackPressureError (for handles) or
return an HTTP 503 status code (for HTTP requests). Defaults to -1 (no
limit).
user_config: Arguments to pass to the reconfigure
method of the deployment. The reconfigure method is called if
user_config is not None. Must be JSON-serializable.
graceful_shutdown_wait_loop_s: Duration
that deployment replicas wait until there is no more work to
be done before shutting down.
graceful_shutdown_timeout_s: Controller waits for this duration
to forcefully kill the replica for shutdown.
health_check_period_s: Frequency at which the controller health
checks replicas.
health_check_timeout_s: Timeout that the controller waits for a
response from the replica's health check before marking it
unhealthy.
autoscaling_config: Autoscaling configuration.
logging_config: Configuration for deployment logs.
user_configured_option_names: The names of options manually
configured by the user.
request_router_config: Configuration for deployment request router.
max_constructor_retry_count: Maximum number of times to retry the
deployment constructor. Defaults to 20.
rolling_update_percentage: The fraction of replicas (of
``target_num_replicas``) to update at a time during a rolling
update. Must be in ``(0.0, 1.0]``. Defaults to 0.2 (20%).
"""
num_replicas: Optional[NonNegativeInt] = Field(
default=1, update_type=DeploymentOptionUpdateType.LightWeight
)
max_ongoing_requests: PositiveInt = Field(
default=DEFAULT_MAX_ONGOING_REQUESTS,
update_type=DeploymentOptionUpdateType.NeedsActorReconfigure,
)
max_queued_requests: int = Field(
default=-1,
update_type=DeploymentOptionUpdateType.LightWeight,
)
user_config: Any = Field(
default=None, update_type=DeploymentOptionUpdateType.NeedsActorReconfigure
)
graceful_shutdown_timeout_s: NonNegativeFloat = Field(
default=DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S,
update_type=DeploymentOptionUpdateType.NeedsReconfigure,
)
graceful_shutdown_wait_loop_s: NonNegativeFloat = Field(
default=DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S,
update_type=DeploymentOptionUpdateType.NeedsActorReconfigure,
)
health_check_period_s: PositiveFloat = Field(
default=DEFAULT_HEALTH_CHECK_PERIOD_S,
update_type=DeploymentOptionUpdateType.NeedsReconfigure,
)
health_check_timeout_s: PositiveFloat = Field(
default=DEFAULT_HEALTH_CHECK_TIMEOUT_S,
update_type=DeploymentOptionUpdateType.NeedsReconfigure,
)
autoscaling_config: Optional[AutoscalingConfig] = Field(
default=None, update_type=DeploymentOptionUpdateType.NeedsActorReconfigure
)
request_router_config: RequestRouterConfig = Field(
default_factory=RequestRouterConfig,
update_type=DeploymentOptionUpdateType.NeedsActorReconfigure,
)
# This flag is used to let replica know they are deployed from
# a different language.
is_cross_language: bool = False
# This flag is used to let controller know which language does
# the deployment use.
deployment_language: Any = DeploymentLanguage.PYTHON
version: Optional[str] = Field(
default=None,
update_type=DeploymentOptionUpdateType.HeavyWeight,
)
logging_config: Optional[dict] = Field(
default=None,
update_type=DeploymentOptionUpdateType.NeedsActorReconfigure,
)
max_constructor_retry_count: PositiveInt = Field(
default=DEFAULT_CONSTRUCTOR_RETRY_COUNT,
update_type=DeploymentOptionUpdateType.NeedsReconfigure,
)
gang_scheduling_config: Optional[GangSchedulingConfig] = Field(
default=None,
update_type=DeploymentOptionUpdateType.HeavyWeight,
)
deployment_actors: Optional[List[DeploymentActorConfig]] = Field(
default=None,
update_type=DeploymentOptionUpdateType.HeavyWeight,
)
rolling_update_percentage: float = Field(
default=DEFAULT_ROLLING_UPDATE_PERCENTAGE,
gt=0.0,
le=1.0,
update_type=DeploymentOptionUpdateType.LightWeight,
)
# Contains the names of deployment options manually set by the user
user_configured_option_names: Set[str] = set()
model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
@field_validator("user_config")
@classmethod
def user_config_json_serializable(cls, v):
if isinstance(v, bytes):
return v
if v is not None:
try:
json.dumps(v)
except TypeError as e:
raise ValueError(f"user_config is not JSON-serializable: {str(e)}.")
return v
@field_validator("logging_config")
@classmethod
def logging_config_valid(cls, v):
if v is None:
return v
if not isinstance(v, dict):
raise TypeError(
f"Got invalid type '{type(v)}' for logging_config. "
"Expected a dictionary."
)
# Handle default value
from ray.serve.schema import LoggingConfig
v = LoggingConfig(**v).model_dump()
return v
@field_validator("max_queued_requests")
@classmethod
def validate_max_queued_requests(cls, v):
if not isinstance(v, int):
raise TypeError("max_queued_requests must be an integer.")
if v < 1 and v != -1:
raise ValueError(
"max_queued_requests must be -1 (no limit) or a positive integer."
)
return v
@model_validator(mode="after")
def validate_gang_scheduling_config(self):
if self.gang_scheduling_config is None:
return self
if (
self.autoscaling_config is not None
and self.autoscaling_config.min_replicas == 0
):
raise ValueError(
"Scale to zero isn't supported for gang-scheduled deployments."
)
# Skip the num_replicas alignment check when autoscaling is enabled
if (
self.autoscaling_config is None
and self.num_replicas is not None
and self.num_replicas % self.gang_scheduling_config.gang_size != 0
):
raise ValueError(
f"num_replicas ({self.num_replicas}) must be a multiple of "
f"gang_size ({self.gang_scheduling_config.gang_size})."
)
return self
@model_validator(mode="after")
def validate_deployment_actors_unique_names(self):
if self.deployment_actors is None:
return self
seen = set()
duplicates = set()
for cfg in self.deployment_actors:
if cfg.name in seen:
duplicates.add(cfg.name)
seen.add(cfg.name)
if duplicates:
raise ValueError(
f"deployment_actors must have unique names. "
f"Duplicate name(s): {sorted(duplicates)}"
)
return self
def needs_pickle(self):
return _needs_pickle(self.deployment_language, self.is_cross_language)
def to_proto(self):
data = self.model_dump()
if data.get("user_config") is not None:
if self.needs_pickle():
data["user_config"] = cloudpickle.dumps(data["user_config"])
if data.get("autoscaling_config"):
# By setting the serialized policy def, on the protobuf level, AutoscalingConfig constructor will not
# try to import the policy from the string import path when the protobuf is deserialized on the controller side
data["autoscaling_config"]["policy"][
"_serialized_policy_def"
] = self.autoscaling_config.policy._serialized_policy_def
# Serialize policy_kwargs dict to bytes for the proto
policy_kwargs = data["autoscaling_config"]["policy"].get("policy_kwargs")
if policy_kwargs is not None:
if not policy_kwargs:
data["autoscaling_config"]["policy"]["policy_kwargs"] = b""
else:
data["autoscaling_config"]["policy"][
"policy_kwargs"
] = cloudpickle.dumps(policy_kwargs)
data["autoscaling_config"] = AutoscalingConfigProto(
**data["autoscaling_config"]
)
if data.get("request_router_config"):
router_kwargs = data["request_router_config"].get("request_router_kwargs")
if router_kwargs is not None:
if not router_kwargs:
data["request_router_config"]["request_router_kwargs"] = b""
elif self.needs_pickle():
# Protobuf requires bytes, so we need to pickle
data["request_router_config"][
"request_router_kwargs"
] = cloudpickle.dumps(router_kwargs)
else:
raise ValueError(
"Non-empty request_router_kwargs not supported"
f"for cross-language deployments. Got: {router_kwargs}"
)
# By setting the serialized request router cls, on the protobuf level, RequestRouterConfig constructor will not
# try to import the request router cls from the string import path when the protobuf is deserialized on the controller side
data["request_router_config"][
"_serialized_request_router_cls"
] = self.request_router_config._serialized_request_router_cls
data["request_router_config"] = RequestRouterConfigProto(
**data["request_router_config"]
)
if data.get("logging_config"):
if "encoding" in data["logging_config"]:
data["logging_config"]["encoding"] = EncodingTypeProto.Value(
data["logging_config"]["encoding"]
)
data["logging_config"] = LoggingConfigProto(**data["logging_config"])
data["user_configured_option_names"] = list(
data["user_configured_option_names"]
)
if data.get("gang_scheduling_config"):
gang_config = data["gang_scheduling_config"]
placement_strategy = GangPlacementStrategyProto.Value(
gang_config["gang_placement_strategy"]
)
failure_policy = GangRuntimeFailurePolicyProto.Value(
gang_config["runtime_failure_policy"]
)
data["gang_scheduling_config"] = GangSchedulingConfigProto(
gang_size=gang_config["gang_size"],
gang_placement_strategy=placement_strategy,
runtime_failure_policy=failure_policy,
)
if self.deployment_actors:
deployment_actors_proto = []
for cfg in self.deployment_actors:
if not cfg._serialized_actor_class:
cfg._serialize_actor_class()
deployment_actors_proto.append(
DeploymentActorConfigProto(
name=cfg.name,
actor_class_name=cfg.actor_class,
_serialized_actor_class=cfg._serialized_actor_class,
serialized_init_args=cloudpickle.dumps(cfg.init_args or ()),
serialized_init_kwargs=cloudpickle.dumps(cfg.init_kwargs or {}),
serialized_actor_options=cloudpickle.dumps(
cfg.actor_options or {}
),
)
)
data["deployment_actors"] = deployment_actors_proto
else:
data.pop("deployment_actors", None)
return DeploymentConfigProto(**data)
def to_proto_bytes(self):
return self.to_proto().SerializeToString()
def to_dict(self):
# only use for logging purposes
return self.model_dump()
@classmethod
def from_proto(cls, proto: DeploymentConfigProto):
data = _proto_to_dict(proto)
deployment_language = (
data["deployment_language"]
if "deployment_language" in data
else DeploymentLanguage.PYTHON
)
is_cross_language = (
data["is_cross_language"] if "is_cross_language" in data else False
)
needs_pickle = _needs_pickle(deployment_language, is_cross_language)
if "user_config" in data:
if data["user_config"] != b"":
if needs_pickle:
data["user_config"] = cloudpickle.loads(proto.user_config)
else:
data["user_config"] = proto.user_config
else:
data["user_config"] = None
if "request_router_config" in data:
if "request_router_kwargs" in data["request_router_config"]:
request_router_kwargs = data["request_router_config"][
"request_router_kwargs"
]
if request_router_kwargs != b"":
if needs_pickle:
data["request_router_config"][
"request_router_kwargs"
] = cloudpickle.loads(
proto.request_router_config.request_router_kwargs
)
else:
data["request_router_config"][
"request_router_kwargs"
] = proto.request_router_config.request_router_kwargs
else:
data["request_router_config"]["request_router_kwargs"] = {}
# Remove falsy proto defaults so Pydantic uses its Field defaults.
# This is important during rolling upgrades when older controllers
# send configs without these fields (proto3 defaults to 0.0).
if not data["request_router_config"].get("initial_backoff_s"):
data["request_router_config"].pop("initial_backoff_s", None)
if not data["request_router_config"].get("backoff_multiplier"):
data["request_router_config"].pop("backoff_multiplier", None)
if not data["request_router_config"].get("max_backoff_s"):
data["request_router_config"].pop("max_backoff_s", None)
data["request_router_config"] = RequestRouterConfig(
**data["request_router_config"]
)
if "autoscaling_config" in data:
if not data["autoscaling_config"].get("upscale_smoothing_factor"):
data["autoscaling_config"]["upscale_smoothing_factor"] = None
if not data["autoscaling_config"].get("downscale_smoothing_factor"):
data["autoscaling_config"]["downscale_smoothing_factor"] = None
if not data["autoscaling_config"].get("upscaling_factor"):
data["autoscaling_config"]["upscaling_factor"] = None
if not data["autoscaling_config"].get("downscaling_factor"):
data["autoscaling_config"]["downscaling_factor"] = None
if not data["autoscaling_config"].get("target_ongoing_requests"):
data["autoscaling_config"]["target_ongoing_requests"] = None
if not data["autoscaling_config"].get("aggregation_function"):
data["autoscaling_config"][
"aggregation_function"
] = AggregationFunction.MEAN
# Deserialize policy_kwargs bytes back to a dict
if "policy" in data["autoscaling_config"]:
policy_data = data["autoscaling_config"]["policy"]
if "policy_kwargs" in policy_data:
raw = policy_data["policy_kwargs"]
if raw and raw != b"":
policy_data["policy_kwargs"] = cloudpickle.loads(
proto.autoscaling_config.policy.policy_kwargs
)
else:
policy_data["policy_kwargs"] = {}
data["autoscaling_config"] = AutoscalingConfig(**data["autoscaling_config"])
if "version" in data:
if data["version"] == "":
data["version"] = None
if "user_configured_option_names" in data:
data["user_configured_option_names"] = set(
data["user_configured_option_names"]
)
if "logging_config" in data:
if "encoding" in data["logging_config"]:
data["logging_config"]["encoding"] = EncodingTypeProto.Name(
data["logging_config"]["encoding"]
)
if "gang_scheduling_config" in data and data["gang_scheduling_config"]:
gang_config = data["gang_scheduling_config"]
gang_config["gang_placement_strategy"] = GangPlacementStrategy(
GangPlacementStrategyProto.Name(gang_config["gang_placement_strategy"])
)
gang_config["runtime_failure_policy"] = GangRuntimeFailurePolicy(
GangRuntimeFailurePolicyProto.Name(
gang_config["runtime_failure_policy"]
)
)
data["gang_scheduling_config"] = GangSchedulingConfig(**gang_config)
else:
data.pop("gang_scheduling_config", None)
if "deployment_actors" in data and data["deployment_actors"]:
deployment_actors = []
def _loads(b):
return cloudpickle.loads(b) if b else None
for proto_dict in data["deployment_actors"]:
serialized_cls = proto_dict.get("_serialized_actor_class")
serialized_args = proto_dict.get("serialized_init_args")
serialized_kwargs = proto_dict.get("serialized_init_kwargs")
serialized_opts = proto_dict.get("serialized_actor_options")
actor_class_name = proto_dict.get("actor_class_name", "")
deployment_actors.append(
DeploymentActorConfig(
name=proto_dict.get("name"),
actor_class=actor_class_name,
_serialized_actor_class=serialized_cls,
init_args=_loads(serialized_args) or (),
init_kwargs=_loads(serialized_kwargs) or {},
actor_options=_loads(serialized_opts) or {},
)
)
data["deployment_actors"] = deployment_actors
else:
data.pop("deployment_actors", None)
return cls(**data)
@classmethod
def from_proto_bytes(cls, proto_bytes: bytes):
proto = DeploymentConfigProto.FromString(proto_bytes)
return cls.from_proto(proto)
@classmethod
def from_default(cls, **kwargs: Any) -> "DeploymentConfig":
"""Creates a default DeploymentConfig and overrides it with kwargs.
Ignores any kwargs set to DEFAULT.VALUE.
Args:
**kwargs: Field overrides for ``DeploymentConfig``. Keys must match
the class's field names; values equal to ``DEFAULT.VALUE`` are
skipped (the default is kept).
Returns:
A ``DeploymentConfig`` initialized from defaults and updated with
the supplied (non-``DEFAULT.VALUE``) kwargs.
Raises:
TypeError: when a keyword that's not an argument to the class is
passed in.
"""
config = cls()
valid_config_options = set(cls.model_fields.keys())
# Friendly error if a non-DeploymentConfig kwarg was passed in
for key, val in kwargs.items():
if key not in valid_config_options:
raise TypeError(
f'Got invalid Deployment config option "{key}" '
f"(with value {val}) as keyword argument. All Deployment "
"config options must come from this list: "
f"{list(valid_config_options)}."
)
kwargs = {key: val for key, val in kwargs.items() if val != DEFAULT.VALUE}
for key, val in kwargs.items():
config.__setattr__(key, val)
return config
def handle_num_replicas_auto(
max_ongoing_requests: Union[int, DEFAULT],
autoscaling_config: Optional[Union[Dict, AutoscalingConfig, DEFAULT]],
):
"""Return modified `max_ongoing_requests` and `autoscaling_config`
for when num_replicas="auto".
If `autoscaling_config` is unspecified, returns the modified value
AutoscalingConfig.default().
If it is specified, the specified fields in `autoscaling_config`
override that of AutoscalingConfig.default().
"""
if autoscaling_config in [DEFAULT.VALUE, None]:
# If autoscaling config wasn't specified, use default
# configuration
autoscaling_config = AutoscalingConfig.default()
else:
# If autoscaling config was specified, values specified in
# autoscaling config overrides the default configuration
default_config = AutoscalingConfig.default().model_dump(exclude_unset=True)
autoscaling_config = (
autoscaling_config
if isinstance(autoscaling_config, dict)
else autoscaling_config.model_dump(exclude_unset=True)
)
default_config.update(autoscaling_config)
autoscaling_config = AutoscalingConfig(**default_config)
return max_ongoing_requests, autoscaling_config
class ReplicaConfig:
"""Internal datastructure wrapping config options for a deployment's replicas.
Provides five main properties (see property docstrings for more info):
deployment_def: the code, or a reference to the code, that this
replica should run.
init_args: the deployment_def's init_args.
init_kwargs: the deployment_def's init_kwargs.
ray_actor_options: the Ray actor options to pass into the replica's
actor.
resource_dict: contains info on this replica's actor's resource needs.
Offers a serialized equivalent (e.g. serialized_deployment_def) for
deployment_def, init_args, and init_kwargs. Deserializes these properties
when they're first accessed, if they were not passed in directly through
create().
Use the classmethod create() to make a ReplicaConfig with the deserialized
properties.
Note: overwriting or setting any property after the ReplicaConfig has been
constructed is currently undefined behavior. The config's fields should not
be modified externally after it is created.
"""
def __init__(
self,
deployment_def_name: str,
serialized_deployment_def: bytes,
serialized_init_args: bytes,
serialized_init_kwargs: bytes,
ray_actor_options: Dict,
placement_group_bundles: Optional[List[Dict[str, float]]] = None,
placement_group_strategy: Optional[str] = None,
placement_group_bundle_label_selector: Optional[List[Dict[str, str]]] = None,
placement_group_fallback_strategy: Optional[List[Dict[str, Any]]] = None,
max_replicas_per_node: Optional[int] = None,
needs_pickle: bool = True,
):
"""Construct a ReplicaConfig with serialized properties.
All parameters are required. See classmethod create() for defaults.
"""
self.deployment_def_name = deployment_def_name
# Store serialized versions of code properties.
self.serialized_deployment_def = serialized_deployment_def
self.serialized_init_args = serialized_init_args
self.serialized_init_kwargs = serialized_init_kwargs
# Deserialize properties when first accessed. See @property methods.
self._deployment_def = None
self._init_args = None
self._init_kwargs = None
# Configure ray_actor_options. These are the Ray options ultimately
# passed into the replica's actor when it's created.
self.ray_actor_options = ray_actor_options
self.placement_group_bundles = placement_group_bundles
self.placement_group_strategy = placement_group_strategy
self.placement_group_bundle_label_selector = (
placement_group_bundle_label_selector
)
self.placement_group_fallback_strategy = placement_group_fallback_strategy
self.max_replicas_per_node = max_replicas_per_node
self._normalize_bundle_label_selector()
self._validate()
# Create resource_dict. This contains info about the replica's resource
# needs. It does NOT set the replica's resource usage. That's done by
# the ray_actor_options.
self.resource_dict = resources_from_ray_options(self.ray_actor_options)
self.needs_pickle = needs_pickle
def _normalize_bundle_label_selector(self):
"""If a single selector is provided for multiple bundles, it is broadcasted
uniformly to all bundles.
"""
if (
self.placement_group_bundles
and self.placement_group_bundle_label_selector
and len(self.placement_group_bundle_label_selector) == 1
and len(self.placement_group_bundles) > 1
):
single_selector = self.placement_group_bundle_label_selector[0]
self.placement_group_bundle_label_selector = [
single_selector.copy() for _ in range(len(self.placement_group_bundles))
]
def _validate(self):
self._validate_ray_actor_options()
self._validate_placement_group_options()
self._validate_max_replicas_per_node()
if (
self.max_replicas_per_node is not None
and self.placement_group_bundles is not None
):
raise ValueError(
"Setting max_replicas_per_node is not allowed when "
"placement_group_bundles is provided."
)
def update(
self,
ray_actor_options: dict,
placement_group_bundles: Optional[List[Dict[str, float]]] = None,
placement_group_strategy: Optional[str] = None,
placement_group_bundle_label_selector: Optional[List[Dict[str, str]]] = None,
placement_group_fallback_strategy: Optional[List[Dict[str, Any]]] = None,
max_replicas_per_node: Optional[int] = None,
):
self.ray_actor_options = ray_actor_options
self.placement_group_bundles = placement_group_bundles
self.placement_group_strategy = placement_group_strategy
self.placement_group_bundle_label_selector = (
placement_group_bundle_label_selector
)
self.placement_group_fallback_strategy = placement_group_fallback_strategy
self.max_replicas_per_node = max_replicas_per_node
self._normalize_bundle_label_selector()
self._validate()
self.resource_dict = resources_from_ray_options(self.ray_actor_options)
@classmethod
def create(
cls,
deployment_def: Union[Callable, str],
init_args: Optional[Tuple[Any]] = None,
init_kwargs: Optional[Dict[Any, Any]] = None,
ray_actor_options: Optional[Dict] = None,
placement_group_bundles: Optional[List[Dict[str, float]]] = None,
placement_group_strategy: Optional[str] = None,
placement_group_bundle_label_selector: Optional[List[Dict[str, str]]] = None,
placement_group_fallback_strategy: Optional[List[Dict[str, Any]]] = None,
max_replicas_per_node: Optional[int] = None,
deployment_def_name: Optional[str] = None,
):
"""Create a ReplicaConfig from deserialized parameters."""
if not callable(deployment_def) and not isinstance(deployment_def, str):
raise TypeError("@serve.deployment must be called on a class or function.")
if not (init_args is None or isinstance(init_args, (tuple, list))):
raise TypeError("init_args must be a tuple.")
if not (init_kwargs is None or isinstance(init_kwargs, dict)):
raise TypeError("init_kwargs must be a dict.")
if inspect.isfunction(deployment_def):
if init_args:
raise ValueError("init_args not supported for function deployments.")
elif init_kwargs:
raise ValueError("init_kwargs not supported for function deployments.")
if not isinstance(deployment_def, (Callable, str)):
raise TypeError(
f'Got invalid type "{type(deployment_def)}" for '
"deployment_def. Expected deployment_def to be a "
"class, function, or string."
)
# Set defaults
if init_args is None:
init_args = ()
if init_kwargs is None:
init_kwargs = {}
if ray_actor_options is None:
ray_actor_options = {}
if deployment_def_name is None:
if isinstance(deployment_def, str):
deployment_def_name = deployment_def
else:
deployment_def_name = deployment_def.__name__
config = cls(
deployment_def_name=deployment_def_name,
serialized_deployment_def=pickle_dumps(
deployment_def,
f"Could not serialize the deployment {repr(deployment_def)}",
),
serialized_init_args=pickle_dumps(
init_args, "Could not serialize the deployment init args"
),
serialized_init_kwargs=pickle_dumps(
init_kwargs, "Could not serialize the deployment init kwargs"
),
ray_actor_options=ray_actor_options,
placement_group_bundles=placement_group_bundles,
placement_group_strategy=placement_group_strategy,
placement_group_bundle_label_selector=placement_group_bundle_label_selector,
placement_group_fallback_strategy=placement_group_fallback_strategy,
max_replicas_per_node=max_replicas_per_node,
)
config._deployment_def = deployment_def
config._init_args = init_args
config._init_kwargs = init_kwargs
return config
def _validate_ray_actor_options(self):
if not isinstance(self.ray_actor_options, dict):
raise TypeError(
f'Got invalid type "{type(self.ray_actor_options)}" for '
"ray_actor_options. Expected a dictionary."
)
# Please keep this in sync with the docstring for the ray_actor_options
# kwarg in api.py.
allowed_ray_actor_options = {
# Resource options
"accelerator_type",
"memory",
"num_cpus",
"num_gpus",
"resources",
# Other options
"runtime_env",
"label_selector",
"fallback_strategy",
}
for option in self.ray_actor_options:
if option not in allowed_ray_actor_options:
raise ValueError(
f"Specifying '{option}' in ray_actor_options is not allowed. "
f"Allowed options: {allowed_ray_actor_options}"
)
ray_option_utils.validate_actor_options(self.ray_actor_options, in_options=True)
# Set Serve replica defaults
if self.ray_actor_options.get("num_cpus") is None:
self.ray_actor_options["num_cpus"] = 1
def _validate_max_replicas_per_node(self) -> None:
if self.max_replicas_per_node is None:
return
if not isinstance(self.max_replicas_per_node, int):
raise TypeError(
f"Get invalid type '{type(self.max_replicas_per_node)}' for "
"max_replicas_per_node. Expected None or an integer "
f"in the range of [1, {MAX_REPLICAS_PER_NODE_MAX_VALUE}]."
)
if (
self.max_replicas_per_node < 1
or self.max_replicas_per_node > MAX_REPLICAS_PER_NODE_MAX_VALUE
):
raise ValueError(
f"Invalid max_replicas_per_node {self.max_replicas_per_node}. "
"Valid values are None or an integer "
f"in the range of [1, {MAX_REPLICAS_PER_NODE_MAX_VALUE}]."
)
def _validate_placement_group_options(self) -> None:
if self.placement_group_strategy is not None:
if self.placement_group_bundles is None:
raise ValueError(
"If `placement_group_strategy` is provided, "
"`placement_group_bundles` must also be provided."
)
if self.placement_group_fallback_strategy is not None:
if self.placement_group_bundles is None:
raise ValueError(
"If `placement_group_fallback_strategy` is provided, "
"`placement_group_bundles` must also be provided."
)
if not isinstance(self.placement_group_fallback_strategy, list):
raise TypeError(
"placement_group_fallback_strategy must be a list of dictionaries. "
f"Got: {type(self.placement_group_fallback_strategy)}."
)
for i, strategy in enumerate(self.placement_group_fallback_strategy):
if not isinstance(strategy, dict):
raise TypeError(
f"placement_group_fallback_strategy entry at index {i} must be a dictionary. "
f"Got: {type(strategy)}."
)
if self.placement_group_bundle_label_selector is not None:
if self.placement_group_bundles is None:
raise ValueError(
"If `placement_group_bundle_label_selector` is provided, "
"`placement_group_bundles` must also be provided."
)
if self.placement_group_bundles is not None:
validate_placement_group(
bundles=self.placement_group_bundles,
strategy=self.placement_group_strategy or "PACK",
lifetime="detached",
bundle_label_selector=self.placement_group_bundle_label_selector,
)
resource_error_prefix = (
"When using `placement_group_bundles`, the replica actor "
"will be placed in the first bundle, so the resource "
"requirements for the actor must be a subset of the first "
"bundle."
)
first_bundle = self.placement_group_bundles[0]
# Validate that the replica actor fits in the first bundle.
# Downstream code depends on this validation. The scheduler pins the
# actor to bundle 0 in deployment_scheduler._schedule_replica, and
# DeploymentSchedulingInfo.required_resources reads bundle 0 as the
# replica's demand.
bundle_cpu = first_bundle.get("CPU", 0)
replica_actor_num_cpus = self.ray_actor_options.get("num_cpus", 0)
if bundle_cpu < replica_actor_num_cpus:
raise ValueError(
f"{resource_error_prefix} `num_cpus` for the actor is "
f"{replica_actor_num_cpus}, but the bundle only has "
f"{bundle_cpu} `CPU` specified."
)
bundle_gpu = first_bundle.get("GPU", 0)
replica_actor_num_gpus = self.ray_actor_options.get("num_gpus", 0)
if bundle_gpu < replica_actor_num_gpus:
raise ValueError(
f"{resource_error_prefix} `num_gpus` for the actor is "
f"{replica_actor_num_gpus}, but the bundle only has "
f"{bundle_gpu} `GPU` specified."
)
replica_actor_resources = self.ray_actor_options.get("resources", {})
for actor_resource, actor_value in replica_actor_resources.items():
bundle_value = first_bundle.get(actor_resource, 0)
if bundle_value < actor_value:
raise ValueError(
f"{resource_error_prefix} `{actor_resource}` requirement "
f"for the actor is {actor_value}, but the bundle only "
f"has {bundle_value} `{actor_resource}` specified."
)
@property
def deployment_def(self) -> Union[Callable, str]:
"""The code, or a reference to the code, that this replica runs.
For Python replicas, this can be one of the following:
- Function (Callable)
- Class (Callable)
- Import path (str)
For Java replicas, this can be one of the following:
- Class path (str)
"""
if self._deployment_def is None:
if self.needs_pickle:
self._deployment_def = cloudpickle.loads(self.serialized_deployment_def)
else:
self._deployment_def = self.serialized_deployment_def.decode(
encoding="utf-8"
)
return self._deployment_def
@property
def init_args(self) -> Optional[Union[Tuple[Any], bytes]]:
"""The init_args for a Python class.
This property is only meaningful if deployment_def is a Python class.
Otherwise, it is None.
"""
if self._init_args is None:
if self.needs_pickle:
self._init_args = cloudpickle.loads(self.serialized_init_args)
else:
self._init_args = self.serialized_init_args
return self._init_args
@property
def init_kwargs(self) -> Optional[Tuple[Any]]:
"""The init_kwargs for a Python class.
This property is only meaningful if deployment_def is a Python class.
Otherwise, it is None.
"""
if self._init_kwargs is None:
self._init_kwargs = cloudpickle.loads(self.serialized_init_kwargs)
return self._init_kwargs
@classmethod
def from_proto(cls, proto: ReplicaConfigProto, needs_pickle: bool = True):
return ReplicaConfig(
deployment_def_name=proto.deployment_def_name,
serialized_deployment_def=proto.deployment_def,
serialized_init_args=(proto.init_args if proto.init_args != b"" else None),
serialized_init_kwargs=(
proto.init_kwargs if proto.init_kwargs != b"" else None
),
ray_actor_options=json.loads(proto.ray_actor_options),
placement_group_bundles=(
json.loads(proto.placement_group_bundles)
if proto.placement_group_bundles
else None
),
placement_group_strategy=(
proto.placement_group_strategy
if proto.placement_group_strategy != ""
else None
),
placement_group_bundle_label_selector=(
json.loads(proto.placement_group_bundle_label_selector)
if proto.placement_group_bundle_label_selector
else None
),
placement_group_fallback_strategy=(
json.loads(proto.placement_group_fallback_strategy)
if proto.placement_group_fallback_strategy
else None
),
max_replicas_per_node=(
proto.max_replicas_per_node if proto.max_replicas_per_node else None
),
needs_pickle=needs_pickle,
)
@classmethod
def from_proto_bytes(cls, proto_bytes: bytes, needs_pickle: bool = True):
proto = ReplicaConfigProto.FromString(proto_bytes)
return cls.from_proto(proto, needs_pickle)
def to_proto(self):
placement_group_bundles = (
json.dumps(self.placement_group_bundles)
if self.placement_group_bundles is not None
else ""
)
bundle_label_selector = (
json.dumps(self.placement_group_bundle_label_selector)
if self.placement_group_bundle_label_selector is not None
else ""
)
fallback_strategy = (
json.dumps(self.placement_group_fallback_strategy)
if self.placement_group_fallback_strategy is not None
else ""
)
max_replicas_per_node = (
self.max_replicas_per_node if self.max_replicas_per_node is not None else 0
)
return ReplicaConfigProto(
deployment_def_name=self.deployment_def_name,
deployment_def=self.serialized_deployment_def,
init_args=self.serialized_init_args,
init_kwargs=self.serialized_init_kwargs,
ray_actor_options=json.dumps(self.ray_actor_options),
placement_group_bundles=placement_group_bundles,
placement_group_strategy=self.placement_group_strategy,
placement_group_bundle_label_selector=bundle_label_selector,
placement_group_fallback_strategy=fallback_strategy,
max_replicas_per_node=max_replicas_per_node,
)
def to_proto_bytes(self):
return self.to_proto().SerializeToString()
def to_dict(self):
# only use for logging purposes
return {
"deployment_def_name": self.deployment_def_name,
"ray_actor_options": self.ray_actor_options,
"placement_group_bundles": self.placement_group_bundles,
"placement_group_strategy": self.placement_group_strategy,
"placement_group_bundle_label_selector": self.placement_group_bundle_label_selector,
"placement_group_fallback_strategy": self.placement_group_fallback_strategy,
"max_replicas_per_node": self.max_replicas_per_node,
}