chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,159 @@
import pprint
from typing import Any, Optional, Union
from pydantic import Field, field_validator
from ray import serve
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.dict_utils import deep_merge_dicts
from ray.llm._internal.serve.constants import RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.builder import (
IngressClsConfig,
_build_direct_streaming_llm_deployment,
_build_openai_ingress_request_router,
_validate_direct_streaming_ingress_config,
)
from ray.llm._internal.serve.core.ingress.ingress import (
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.server.builder import build_llm_deployment
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import (
DPServer,
)
from ray.serve.deployment import Application
logger = get_logger(__name__)
def build_dp_deployment(
llm_config: LLMConfig,
*,
name_prefix: Optional[str] = None,
bind_kwargs: Optional[dict] = None,
override_serve_options: Optional[dict] = None,
deployment_cls: Optional[type] = None,
) -> Application:
"""Build a data parallel attention LLM deployment.
Args:
llm_config: The LLM configuration.
name_prefix: The prefix to add to the deployment name.
bind_kwargs: Optional extra kwargs to pass to the deployment constructor.
Used by PD disaggregation to inject prefill_server handles.
override_serve_options: The optional serve options to override the
default options.
deployment_cls: Optional deployment class to use. Defaults to DPServer.
Returns:
The Ray Serve Application for the data parallel attention LLM deployment.
"""
return build_llm_deployment(
llm_config,
name_prefix=name_prefix,
bind_kwargs=bind_kwargs,
override_serve_options=override_serve_options,
deployment_cls=deployment_cls or DPServer,
)
class DPOpenAiServingArgs(BaseModelExtended):
"""Schema for DP OpenAI serving args."""
llm_config: Union[str, dict, LLMConfig] = Field(
description="The LLM configuration",
)
ingress_cls_config: Union[dict, IngressClsConfig] = Field(
default_factory=IngressClsConfig,
description="The configuration for the ingress class.",
)
ingress_deployment_config: Optional[dict] = Field(
default_factory=dict,
description="The Ray @server.deployment options for the ingress server.",
)
@field_validator("llm_config")
@classmethod
def _validate_llm_config(cls, value: Any) -> LLMConfig:
if isinstance(value, str):
return LLMConfig.from_file(value)
elif isinstance(value, dict):
return LLMConfig.model_validate(value)
elif isinstance(value, LLMConfig):
return value
else:
raise TypeError(f"Invalid LLMConfig type: {type(value)}")
@field_validator("ingress_cls_config")
@classmethod
def _validate_ingress_cls_config(cls, value: Any) -> IngressClsConfig:
if isinstance(value, dict):
return IngressClsConfig.model_validate(value)
return value
def build_dp_openai_app(builder_config: dict) -> Application:
"""Build an OpenAI compatible app with the DP attention deployment
setup from the given builder configuration.
Args:
builder_config: The configuration for the builder. It has to conform
to the DPOpenAiServingArgs pydantic model.
Returns:
The configured Ray Serve Application.
"""
builder_config = DPOpenAiServingArgs.model_validate(builder_config)
llm_config = builder_config.llm_config
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
_validate_direct_streaming_ingress_config(
builder_config.ingress_deployment_config,
builder_config.ingress_cls_config,
)
direct_deployment = _build_direct_streaming_llm_deployment(
llm_config,
deployment_cls=DPServer,
)
logger.info(
"Direct streaming enabled for DP: "
"DPServer=ingress, LLMRouter=ingress_request_router"
)
return direct_deployment._with_ingress_request_router(
_build_openai_ingress_request_router(
server=direct_deployment, llm_config=llm_config
)
)
dp_deployment = build_dp_deployment(llm_config)
ingress_cls_config = builder_config.ingress_cls_config
ingress_options = ingress_cls_config.ingress_cls.get_deployment_options(
[llm_config]
)
if builder_config.ingress_deployment_config:
ingress_options = deep_merge_dicts(
ingress_options, builder_config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(ingress_cls_config.ingress_cls)
logger.info("============== Ingress Options ==============")
logger.info(pprint.pformat(ingress_options))
model_id = llm_config.model_id
lora_config = llm_config.lora_config
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={model_id: dp_deployment},
model_cards={model_id: to_model_metadata(model_id, llm_config)},
lora_paths=(
{model_id: lora_config.dynamic_lora_loading_path}
if lora_config is not None
else {}
),
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,297 @@
import asyncio
import json
import logging
import os
import time
from typing import List, Optional, Tuple, Type
import ray
from ray import serve
from ray.experimental.internal_kv import (
_internal_kv_del,
_internal_kv_get,
_internal_kv_put,
)
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.engine.protocol import LLMEngine
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm._internal.serve.utils.lora_serve_utils import LoraModelLoader
from ray.llm._internal.serve.utils.pg_utils import get_bundle_indices_sorted_by_node
from ray.serve.config import (
AutoscalingConfig,
GangPlacementStrategy,
GangRuntimeFailurePolicy,
GangSchedulingConfig,
)
from ray.util.collective.collective import get_address_and_port
from ray.util.placement_group import get_placement_group
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 120
POLL_INTERVAL_SECONDS = 0.5
class GangMasterInfoRegistry:
"""Registry for gang DP master info using GCS KV store."""
_KEY_PREFIX = "LLMServeRegistry:serve_global:gang_dp_master/"
@classmethod
def _make_key(cls, gang_id: str) -> bytes:
return (cls._KEY_PREFIX + gang_id).encode("utf-8")
@classmethod
def register(cls, gang_id: str, address: str, port: int, node_id: str) -> None:
"""Store the DP master info in GCS KV store."""
key = cls._make_key(gang_id)
value = json.dumps(
{"address": address, "port": port, "node_id": node_id}
).encode("utf-8")
_internal_kv_put(key, value, overwrite=True)
@classmethod
def unregister(cls, gang_id: str) -> None:
"""Remove the DP master info from GCS KV store."""
key = cls._make_key(gang_id)
try:
_internal_kv_del(key)
except Exception:
logger.warning(
f"Failed to unregister gang master info for gang {gang_id}.",
exc_info=True,
)
@classmethod
async def get(
cls,
gang_id: str,
timeout: float = TIMEOUT_SECONDS,
poll_interval: float = POLL_INTERVAL_SECONDS,
) -> Tuple[str, int, str]:
"""Retrieve the DP master info for gang_id, polling until available.
Args:
gang_id: The ID of the gang.
timeout: The timeout in seconds.
poll_interval: The poll interval in seconds.
Returns:
A tuple of (address, port, node_id).
Raises:
TimeoutError: If the info is not available within timeout_seconds seconds.
"""
key = cls._make_key(gang_id)
deadline = time.monotonic() + timeout
while True:
data = _internal_kv_get(key)
if data is not None:
info = json.loads(data)
return info["address"], info["port"], info["node_id"]
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out waiting for DP master info for gang {gang_id} "
f"after {timeout}s."
)
await asyncio.sleep(poll_interval)
class DPServer(LLMServer):
"""
Gang-scheduled Data Parallel LLM Server.
Uses Ray Serve's gang scheduling so that if any replica in a DP group deployment
fails, the entire group is restarted atomically.
"""
async def __init__(
self,
llm_config: LLMConfig,
*,
engine_cls: Optional[Type[LLMEngine]] = None,
model_downloader: Optional[Type[LoraModelLoader]] = None,
):
ctx = serve.get_replica_context()
gang_context = ctx.gang_context
if gang_context is None:
raise RuntimeError(
"DPServer requires gang scheduling to be enabled. "
"Set gang_scheduling_config in the deployment options."
)
self.dp_rank = gang_context.rank
self.gang_id = gang_context.gang_id
self.dp_size = gang_context.world_size
logger.info(
f"DPServer replica initialized: dp_rank={self.dp_rank}, "
f"dp_size={self.dp_size}, gang_id={self.gang_id}"
)
if self.dp_rank == 0:
self.dp_address, self.dp_rpc_port = get_address_and_port()
# Record rank 0's node so every replica places vLLM's global rank 0
# worker (which hosts the distributed rendezvous store) on the same
# node whose address we advertise below. See the bundle-ordering
# comment in __init__ for why this matters.
self.dp_node_id = ray.get_runtime_context().get_node_id()
GangMasterInfoRegistry.register(
self.gang_id, self.dp_address, self.dp_rpc_port, self.dp_node_id
)
logger.info(
f"DP rank {self.dp_rank} has set DP master info: "
f"data_parallel_address={self.dp_address}, "
f"data_parallel_rpc_port={self.dp_rpc_port}, "
f"data_parallel_node_id={self.dp_node_id}"
)
else:
timestamp = time.time()
(
self.dp_address,
self.dp_rpc_port,
self.dp_node_id,
) = await GangMasterInfoRegistry.get(self.gang_id)
logger.info(
f"DP rank {self.dp_rank} got DP master info: "
f"data_parallel_address={self.dp_address}, "
f"data_parallel_rpc_port={self.dp_rpc_port}, "
f"data_parallel_node_id={self.dp_node_id}, "
f"waited {time.time() - timestamp:.3f} seconds"
)
# Update the engine_kwargs to assign the DP information
llm_config.update_engine_kwargs(
data_parallel_rank=self.dp_rank,
data_parallel_address=self.dp_address,
data_parallel_rpc_port=self.dp_rpc_port,
)
engine_config = llm_config.get_engine_config()
# Direct vLLM to use this replica's bundles within the gang placement group.
# Gang placement group concatenates per-replica bundles for all ranks, so
# rank i owns bundles [i*B, i*B+1, ..., i*B+B-1] where B is the number of
# bundles per DP replica.
#
# However, adjacent bundle indices in a placement group don't necessarily
# map to adjacent physical ranks. We use get_bundle_indices_sorted_by_node
# to reorder bundle indices so that same-node bundles are adjacent and
# rank 0's node bundles come first. This prevents us from scattering
# adjacent TP ranks in the same DP rank across nodes.
#
# Ordering rank 0's node first is also required for correctness: vLLM
# forms a single distributed group across all DP workers whose rendezvous
# store is hosted by global rank 0 and reached at the advertised
# data_parallel_address (set above from rank 0's node). vLLM pins global
# rank 0 to sorted_indices[0], so that bundle must live on the same node
# whose address we advertised. Sorting by the cluster head node instead
# (the previous default) breaks this whenever the head node owns no
# bundles in the gang (e.g. a CPU-only head in a GPU cluster): rank 0
# then lands on an arbitrary node, the store binds there, and every
# worker hangs connecting to the wrong (advertised) address until the
# distributed-init timeout fires and the gang is restarted.
#
# Example: dp_size=2, tp_size=2, 2 GPUs per node for simplicity
# Gang placement group = [{GPU: 1}, {GPU: 1}, {GPU: 1}, {GPU: 1}]
# Physical rank location: ^^N0R0^^ ^^N1R1^^ ^^N0R1^^ ^^N1R0^^
# DP placement: ^^DP0^^^ ^^DP1^^^ ^^DP0^^^ ^^DP1^^^
#
# placement_bundles below is the gang placement group, and therefore
# get_current_placement_group from the actor yields the gang placement group,
# not the per-replica placement group.
bundles_per_replica = len(engine_config.placement_bundles)
pg = get_placement_group(gang_context.pg_name)
sorted_indices = get_bundle_indices_sorted_by_node(
pg, driver_node_id=self.dp_node_id
)
os.environ["VLLM_RAY_BUNDLE_INDICES"] = self._compute_bundle_indices(
self.dp_rank, bundles_per_replica, sorted_indices
)
await super().__init__(
llm_config,
engine_cls=engine_cls,
model_downloader=model_downloader,
)
@staticmethod
def _compute_bundle_indices(
dp_rank: int, bundles_per_replica: int, sorted_indices: List[int]
) -> str:
"""Return the VLLM_RAY_BUNDLE_INDICES value for a given DP rank.
Slices into sorted_indices (bundle indices reordered so that
same-node bundles are adjacent) to pick the bundles that belong to
this DP rank.
Args:
dp_rank: This replica's data-parallel rank.
bundles_per_replica: Number of placement-group bundles each DP
replica owns.
sorted_indices: Bundle indices sorted by node.
Returns:
Comma-separated bundle indices, e.g. "0,2".
"""
start = dp_rank * bundles_per_replica
return ",".join(
str(sorted_indices[start + i]) for i in range(bundles_per_replica)
)
@classmethod
def get_deployment_options(cls, llm_config: "LLMConfig"):
deployment_options = super().get_deployment_options(llm_config)
dp_size = llm_config.engine_kwargs.get("data_parallel_size", 1)
if not (isinstance(dp_size, int) and dp_size > 0):
raise ValueError(
f"Invalid data_parallel_size: {dp_size}, expecting positive integer."
)
if dp_size != 1:
num_replicas = deployment_options.get("num_replicas")
has_autoscaling = num_replicas == "auto" or (
num_replicas is None and "autoscaling_config" in deployment_options
)
if has_autoscaling:
autoscaling_config = AutoscalingConfig.default().dict()
user_config = deployment_options.get("autoscaling_config")
if user_config is not None:
autoscaling_config.update(user_config)
logger.warning(
"In DP deployment, a replica refers to a DP group. "
"Multiplying autoscaling_config's min_replicas, max_replicas, and "
f"initial_replicas by data_parallel_size ({dp_size})."
)
for key in ["min_replicas", "max_replicas", "initial_replicas"]:
if autoscaling_config.get(key) is not None:
autoscaling_config[key] *= dp_size
deployment_options["autoscaling_config"] = autoscaling_config
elif num_replicas is not None:
logger.warning(
"In DP deployment, num_replicas refers to the number of DP groups. "
f"Multiplying num_replicas ({num_replicas}) by data_parallel_size ({dp_size}) "
f"to get the total number of serve replicas ({num_replicas * dp_size})."
)
deployment_options["num_replicas"] = num_replicas * dp_size
else:
deployment_options["num_replicas"] = dp_size
deployment_options["gang_scheduling_config"] = GangSchedulingConfig(
gang_size=dp_size,
gang_placement_strategy=GangPlacementStrategy.PACK,
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_GANG,
)
# Remove per-replica placement_group_strategy. Ray Serve raises an error
# if both placement_group_strategy and gang_scheduling_config are provided.
if "placement_group_strategy" in deployment_options:
logger.warning(
"placement_group_strategy configured in the deployment config is ignored. "
"DP deployment uses PACK strategy for scheduling DP groups."
)
deployment_options.pop("placement_group_strategy", None)
return deployment_options
@@ -0,0 +1,305 @@
"""Using Ray Serve to deploy LLM models with P/D disaggregation.
3-tier graph: ingress -> PDDecodeServer (decode config + engine) -> PDPrefillServer.
"""
import warnings
from typing import Any, Optional, Union
from pydantic import Field, field_validator, model_validator
from ray import serve
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.dict_utils import (
maybe_apply_llm_deployment_config_defaults,
)
from ray.llm._internal.serve.constants import RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.builder import (
IngressClsConfig,
_build_direct_streaming_llm_deployment,
_build_openai_ingress_request_router,
_validate_direct_streaming_ingress_config,
load_class,
)
from ray.llm._internal.serve.core.ingress.ingress import (
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.server.builder import build_llm_deployment
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.serving_patterns.data_parallel.builder import (
build_dp_deployment,
)
from ray.llm._internal.serve.serving_patterns.prefill_decode.pd_server import (
DPPDDecodeServer,
DPPDPrefillServer,
PDDecodeServer,
PDPrefillServer,
PDProxyServer, # TODO(Kourosh): Deprecate, remove in Ray 2.58.
)
from ray.serve.deployment import Application
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Deprecated: ProxyClsConfig
# TODO(Kourosh): Deprecate, remove in Ray 2.58.
# ---------------------------------------------------------------------------
class ProxyClsConfig(BaseModelExtended):
"""Deprecated. Unused proxy configuration kept for backwards compatibility."""
proxy_cls: Union[str, type] = Field(
default=PDProxyServer,
description="Deprecated.",
)
proxy_extra_kwargs: Optional[dict] = Field(
default_factory=dict,
description="Deprecated.",
)
@field_validator("proxy_cls")
@classmethod
def validate_class(cls, value):
if isinstance(value, str):
return load_class(value)
return value
# ---------------------------------------------------------------------------
# PDServingArgs
# ---------------------------------------------------------------------------
class PDServingArgs(BaseModelExtended):
"""Schema for P/D serving args.
Defines the prefill and decode LLMConfigs plus ingress options.
The deprecated ``proxy_cls_config`` and ``proxy_deployment_config``
fields are accepted for backwards compatibility but ignored.
"""
prefill_config: Union[str, dict, LLMConfig]
decode_config: Union[str, dict, LLMConfig]
# TODO(Kourosh): Deprecated, remove in Ray 2.58.
# Deprecated proxy fields — accepted for backwards compat, ignored at build time.
proxy_cls_config: Optional[Union[dict, ProxyClsConfig]] = Field(
default=None,
description="Deprecated. Accepted but ignored.",
)
proxy_deployment_config: Optional[dict] = Field(
default=None,
description="Deprecated. Accepted but ignored.",
)
ingress_cls_config: Union[dict, IngressClsConfig] = Field(
default_factory=IngressClsConfig,
description="The configuration for the ingress class.",
)
ingress_deployment_config: Optional[dict] = Field(
default_factory=dict,
description="The Ray @serve.deployment options for the ingress.",
)
@field_validator("prefill_config", "decode_config")
@classmethod
def _validate_llm_config(cls, value: Any) -> LLMConfig:
if isinstance(value, str):
return LLMConfig.from_file(value)
elif isinstance(value, dict):
return LLMConfig.model_validate(value)
elif isinstance(value, LLMConfig):
return value
else:
raise TypeError(f"Invalid LLMConfig type: {type(value)}")
@field_validator("proxy_cls_config")
@classmethod
def _validate_proxy_cls_config(
cls, value: Optional[Union[dict, ProxyClsConfig]]
) -> Optional[ProxyClsConfig]:
if value is not None:
warnings.warn(
"proxy_cls_config is deprecated and ignored. "
"The proxy has been replaced by PDDecodeServer which "
"orchestrates prefill and decode directly. "
"See PDDecodeServer and PDPrefillServer.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(value, dict):
return ProxyClsConfig.model_validate(value)
return value
@field_validator("proxy_deployment_config")
@classmethod
def _validate_proxy_deployment_config(cls, value: Optional[dict]) -> Optional[dict]:
if value is not None:
warnings.warn(
"proxy_deployment_config is deprecated and ignored. "
"The proxy has been replaced by PDDecodeServer which "
"orchestrates prefill and decode directly. "
"See PDDecodeServer and PDPrefillServer.",
DeprecationWarning,
stacklevel=2,
)
return value
@field_validator("ingress_cls_config")
@classmethod
def _validate_ingress_cls_config(
cls, value: Union[dict, IngressClsConfig]
) -> IngressClsConfig:
if isinstance(value, dict):
return IngressClsConfig.model_validate(value)
return value
@model_validator(mode="after")
def _validate_model_ids(self):
"""Validate that prefill and decode configs use the same model ID."""
if self.prefill_config.model_id != self.decode_config.model_id:
raise ValueError("P/D model id mismatch")
return self
@model_validator(mode="after")
def _validate_kv_transfer_config(self):
"""Validate that kv_transfer_config is set for both prefill and decode configs."""
for config in [self.prefill_config, self.decode_config]:
if config.engine_kwargs.get("kv_transfer_config") is None:
raise ValueError(
"kv_transfer_config is required for P/D disaggregation"
)
return self
@model_validator(mode="after")
def _default_decode_nixl_port_base(self):
"""Shift decode's NIXL base off prefill's default (20000) so colocated replicas don't collide."""
self.decode_config.experimental_configs.setdefault(
"NIXL_SIDE_CHANNEL_PORT_BASE", 22000
)
return self
@model_validator(mode="after")
def _default_decode_moriio_port_base(self):
"""Shift decode's MoRIIO handshake/notify bases off prefill's defaults.
Mirrors ``_default_decode_nixl_port_base``: a colocated P+D pair on one
node would otherwise share MoRIIO's default handshake/notify ports. Only
applies when the decode config uses the MoRIIO connector. The +1000
stride is well above any realistic tp_size*pp_size offset added on top.
"""
kv_transfer_config = (
self.decode_config.engine_kwargs.get("kv_transfer_config") or {}
)
if kv_transfer_config.get("kv_connector") != "MoRIIOConnector":
return self
from ray.llm._internal.serve.engines.vllm.kv_transfer.moriio import (
DEFAULT_HANDSHAKE_PORT_BASE,
DEFAULT_NOTIFY_PORT_BASE,
HANDSHAKE_PORT_BASE_KEY,
NOTIFY_PORT_BASE_KEY,
)
self.decode_config.experimental_configs.setdefault(
HANDSHAKE_PORT_BASE_KEY, DEFAULT_HANDSHAKE_PORT_BASE + 1000
)
self.decode_config.experimental_configs.setdefault(
NOTIFY_PORT_BASE_KEY, DEFAULT_NOTIFY_PORT_BASE + 1000
)
return self
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_pd_openai_app(pd_serving_args: dict) -> Application:
"""Build a deployable application utilizing prefill/decode disaggregation.
3-tier graph: ingress -> PDDecodeServer -> PDPrefillServer.
"""
pd_config = PDServingArgs.model_validate(pd_serving_args)
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
_validate_direct_streaming_ingress_config(
pd_config.ingress_deployment_config,
pd_config.ingress_cls_config,
)
prefill_dp_size = pd_config.prefill_config.engine_kwargs.get(
"data_parallel_size", 1
)
decode_dp_size = pd_config.decode_config.engine_kwargs.get("data_parallel_size", 1)
prefill_builder = (
build_dp_deployment if prefill_dp_size > 1 else build_llm_deployment
)
# When DP > 1, use combined DP+PD server classes that inherit from both
# the PD server and DPServer (for gang scheduling, DP master info, etc.).
prefill_cls = DPPDPrefillServer if prefill_dp_size > 1 else PDPrefillServer
decode_cls = DPPDDecodeServer if decode_dp_size > 1 else PDDecodeServer
prefill_deployment = prefill_builder(
pd_config.prefill_config,
name_prefix="Prefill:",
deployment_cls=prefill_cls,
)
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
# Direct streaming makes decode the ASGI ingress, so it must be built
# with the ASGI wrapper while still receiving the prefill backend.
decode_deployment = _build_direct_streaming_llm_deployment(
pd_config.decode_config,
name_prefix="Decode:",
bind_kwargs={"prefill_server": prefill_deployment},
deployment_cls=decode_cls,
)
logger.info(
"Direct streaming enabled for PD: "
f"{decode_cls.__name__}=ingress, LLMRouter=ingress_request_router"
)
return decode_deployment._with_ingress_request_router(
_build_openai_ingress_request_router(
server=decode_deployment, llm_config=pd_config.decode_config
)
)
decode_builder = build_dp_deployment if decode_dp_size > 1 else build_llm_deployment
decode_deployment = decode_builder(
pd_config.decode_config,
name_prefix="Decode:",
bind_kwargs={"prefill_server": prefill_deployment},
deployment_cls=decode_cls,
)
# -- Ingress: binds to decode only (the "model" the client sees) --
ingress_cls_config = pd_config.ingress_cls_config
default_ingress_options = ingress_cls_config.ingress_cls.get_deployment_options(
[pd_config.decode_config]
)
ingress_options = maybe_apply_llm_deployment_config_defaults(
default_ingress_options, pd_config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(ingress_cls_config.ingress_cls)
# Prefill and decode share the same model_id (validated in PDServingArgs).
# Ingress binds to decode only (the "model" the client sees).
model_id = pd_config.decode_config.model_id
lora_config = pd_config.decode_config.lora_config
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={model_id: decode_deployment},
model_cards={model_id: to_model_metadata(model_id, pd_config.decode_config)},
lora_paths=(
{model_id: lora_config.dynamic_lora_loading_path}
if lora_config is not None
else {}
),
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,870 @@
"""Prefill-Decode disaggregated LLM serving: decode-as-orchestrator architecture.
3-tier graph (ingress -> PDDecodeServer -> PDPrefillServer) where the
decode deployment owns a real engine and orchestrates remote prefill.
"""
import asyncio
import contextlib
import logging
import uuid
import warnings
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
from fastapi.routing import APIRoute
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from ray.llm._internal.common.patches.vllm.tokenize_once import (
install as _install_tokenize_once,
reuse_prompt_token_ids as _reuse_prompt_token_ids,
)
from ray.llm._internal.serve.constants import DEFAULT_MAX_ONGOING_REQUESTS
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorResponse,
)
from ray.llm._internal.serve.core.ingress.utils import (
NON_STREAMING_RESPONSE_TYPES,
_openai_json_wrapper,
_peek_at_generator,
_sanitize_chat_completion_request,
)
from ray.llm._internal.serve.core.protocol import LLMServerProtocol, RawRequestInfo
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import BaseConnectorBackend
from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import DPServer
from ray.llm._internal.serve.utils.broadcast import broadcast
from ray.llm._internal.serve.utils.server_utils import (
get_serve_request_id,
)
from ray.serve._private.http_util import session_id_from_headers
from ray.serve.exceptions import DeploymentUnavailableError
from ray.serve.handle import DeploymentHandle
from ray.serve.llm import LLMConfig
logger = logging.getLogger(__name__)
RequestType = Union[ChatCompletionRequest, CompletionRequest]
# TODO(Kourosh): Deprecate in Ray 2.56, remove in Ray 2.58.
DEFAULT_PD_PROXY_SERVER_OPTIONS = {
"max_ongoing_requests": DEFAULT_MAX_ONGOING_REQUESTS,
}
_PREWARM_PROMPT = " x"
_PREWARM_MAX_TOKENS = 1
_PREWARM_RETRY_INTERVAL_S = 5.0
_PREWARM_MAX_RETRIES = 60
# ---------------------------------------------------------------------------
# Direct-streaming route helpers
# ---------------------------------------------------------------------------
#
# Direct streaming exposes the engine-native ASGI app directly on the LLM
# server replica (see ``LLMServer.__serve_build_asgi_app__``), eliminating the
# separate ``OpenAiIngress`` deployment. For P/D, the engine-native
# chat/completions routes would send traffic straight to the local decode
# engine and bypass remote prefill, so ``PDOrchestratorMixin`` re-points just
# those two routes at its own ``chat`` / ``completions`` (which orchestrate
# prefill then decode). Every other route stays engine-native, identical to
# non-P/D direct streaming.
def _strip_routes(app, path: str) -> None:
"""Remove the engine-native APIRoute(s) registered at ``path``."""
app.routes[:] = [
r for r in app.routes if not (isinstance(r, APIRoute) and r.path == path)
]
async def _pd_http_response(gen) -> Response:
"""Shape a P/D orchestration generator into an OpenAI HTTP response.
Returns a JSON response when the first chunk is an error or a complete
(non-streaming) response, otherwise an SSE stream. Uses the same response
helpers as ``OpenAiIngress`` so the wire format matches the standard path.
"""
first, gen = await _peek_at_generator(gen)
if isinstance(first, list):
first = first[0]
if isinstance(first, ErrorResponse):
return JSONResponse(
content=first.model_dump(), status_code=first.error.code or 400
)
if isinstance(first, NON_STREAMING_RESPONSE_TYPES):
return JSONResponse(content=first.model_dump())
return StreamingResponse(_openai_json_wrapper(gen), media_type="text/event-stream")
# ---------------------------------------------------------------------------
# Mixin: PD Orchestration Logic
# ---------------------------------------------------------------------------
class PDOrchestratorMixin:
"""Mixin that adds prefill-decode orchestration to an LLMServer subclass.
For chat/completions requests it:
1. Sends a modified prefill request (max_tokens=1, kv_transfer_params).
2. Receives kv_transfer_params back from the first prefill chunk.
3. Runs decode locally on its own engine with those params.
"""
# Set by __init__ of the concrete class that mixes this in.
_prefill_handle: DeploymentHandle
# Decode reuses prefill's prompt token ids. Set from experimental_configs in
# PDDecodeServer.__init__. Default off.
_pd_tokenize_once: bool = False
# ---- Connector backend resolution ----
def _get_connector_backend(self) -> BaseConnectorBackend:
"""Return the connector backend that was set up during engine init.
``LLMConfig.setup_engine_backend()`` creates the backend and calls its
``setup()`` during engine initialization, storing it on the config. By the
time a request reaches the orchestrator it must already be there — a
missing backend means engine init was skipped, which is a bug (and a
freshly-created, un-``setup()`` backend would mis-shape traffic, e.g. a
MultiConnector whose sub-connectors are populated only in ``setup()``).
Cached on first access since the request path calls this.
"""
cached = getattr(self, "_connector_backend_cache", None)
if cached is not None:
return cached
backend = getattr(self._llm_config, "kv_connector_backend", None)
assert backend is not None, (
"No KV-connector backend on the LLMConfig. setup_engine_backend() must "
"run during engine init before the P/D orchestrator handles requests."
)
self._connector_backend_cache = backend
return backend
# ---- Request Preparation ----
#
# Thin instance delegates to the resolved backend's protocol so existing
# callers/tests that reference these names keep working. The orchestrator
# itself goes through ``backend.prepare_*`` directly.
def _prepare_prefill_request(self, request: RequestType) -> RequestType:
return self._get_connector_backend().prepare_prefill_request(
request=request, peer=None
)
def _prepare_decode_request(
self,
request: RequestType,
prefill_chunk: Union[ChatCompletionResponse, CompletionResponse],
) -> RequestType:
return self._get_connector_backend().prepare_decode_request(
request=request, peer=None, prefill_response=prefill_chunk
)
def _decode_reuse_ids(self, prefill_chunk) -> Optional[list]:
"""Prompt token ids for decode to reuse, or None when disabled or absent.
Chat carries them top-level. Completions carry them on the first choice as
``CompletionResponseChoice.prompt_token_ids``."""
if not self._pd_tokenize_once:
return None
ids = getattr(prefill_chunk, "prompt_token_ids", None)
if ids is None:
choices = getattr(prefill_chunk, "choices", None)
if choices:
ids = getattr(choices[0], "prompt_token_ids", None)
return ids
def _request_prefill_token_ids(self, prefill_request) -> None:
"""Ask prefill to echo its prompt token ids so decode can reuse them.
No-op when disabled or the request lacks the field. Used on sequential handoff
only. Concurrent decode starts before prefill returns, so it has nothing to
reuse."""
if self._pd_tokenize_once and hasattr(prefill_request, "return_token_ids"):
prefill_request.return_token_ids = True
# ---- Orchestrated Request Flow ----
async def _pd_handle_request(
self,
request: RequestType,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[str, ChatCompletionResponse, CompletionResponse, ErrorResponse], None
]:
"""Orchestrate prefill (remote) then decode (local engine).
Request shaping, peer addressing, and handoff discipline are delegated to
the resolved KV-connector backend. With the default backend flags
(``requires_peer_binding=False``, ``concurrent_handoff=False``) the
control flow and calls are identical to the historical NIXL/default flow.
A connector that encodes coordination data in the request id (MoRIIO's
dual-address id) just stamps ``request.request_id`` in ``prepare_*``; it
then reaches both engines unchanged -- the LLMServer pipeline preserves
an explicitly-set request_id (it no longer clobbers it with the Serve
id) and the engine copies it into the ``X-Request-Id`` header it reads.
"""
# Determine method name for the handle call
if isinstance(request, ChatCompletionRequest):
method = "chat"
elif isinstance(request, CompletionRequest):
method = "completions"
else:
raise ValueError(f"Unsupported request type: {type(request)}")
backend = self._get_connector_backend()
prefill_handle = self._prefill_handle
if raw_request_info is not None:
session_id = session_id_from_headers(raw_request_info.headers)
if session_id:
prefill_handle = prefill_handle.options(session_id=session_id)
prefill_handle_method = getattr(prefill_handle, method)
if backend.requires_peer_binding:
# Connector needs to bind to the selected prefill replica *before*
# dispatch (e.g. request-id-addressed transfers). Reserve a replica
# via choose_replica, expose its metadata to the backend, then
# dispatch onto that exact selection.
async with prefill_handle_method.choose_replica(request) as selection:
# The selected replica's published metadata (empty dict if none).
peer = getattr(selection, "replica_metadata", {})
prefill_request = backend.prepare_prefill_request(
request=request, peer=peer
)
if backend.concurrent_handoff:
# Concurrent handoff: start remote prefill and run local decode
# together, draining prefill before leaving the choose_replica
# context (so on_request_completed fires once).
decode_request = backend.prepare_decode_request(
request=request, peer=peer, prefill_response=None
)
prefill_resp = prefill_handle_method.dispatch(
selection, prefill_request, raw_request_info
)
# dispatch()'s completion accounting fires when its result
# completes, so the response must be drained to exhaustion
# inside the choose_replica context — never cancelled
# (prefill is clamped to a single token, so draining is
# bounded).
async for chunk in self._concurrent_decode(
method,
decode_request,
prefill_resp,
raw_request_info,
cancel_on_failure=False,
):
yield chunk
return
# Sequential handoff with peer binding: run prefill to its first
# chunk, then drive local decode with the returned params.
self._request_prefill_token_ids(prefill_request)
prefill_gen = prefill_handle_method.dispatch(
selection, prefill_request, raw_request_info
)
prefill_chunk = await prefill_gen.__anext__()
# Drain the dispatched stream to exhaustion inside the
# choose_replica context: dispatch()'s completion accounting
# (queue-length cache decrement) fires when the result
# completes. Prefill is clamped to a single token, so this is
# at most one trivial extra iteration.
async for _ in prefill_gen:
pass
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
decode_request = backend.prepare_decode_request(
request=request, peer=peer, prefill_response=prefill_chunk
)
with _reuse_prompt_token_ids(self._decode_reuse_ids(prefill_chunk)):
local_gen = await getattr(super(), method)(
decode_request, raw_request_info
)
async for chunk in local_gen:
yield chunk
return
# Default path: no pre-dispatch peer binding; dispatch prefill via the
# standard handle path.
prefill_request = backend.prepare_prefill_request(request=request, peer=None)
if backend.concurrent_handoff:
# Concurrent handoff: dispatch via remote() and run local decode
# together.
decode_request = backend.prepare_decode_request(
request=request, peer=None, prefill_response=None
)
prefill_resp = prefill_handle_method.remote(
prefill_request, raw_request_info
)
async for chunk in self._concurrent_decode(
method, decode_request, prefill_resp, raw_request_info
):
yield chunk
return
# 1. Remote prefill
self._request_prefill_token_ids(prefill_request)
prefill_gen = prefill_handle_method.remote(prefill_request, raw_request_info)
prefill_chunk = await prefill_gen.__anext__()
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
# 2. Local decode via super().chat / super().completions so the
# standard LLMServer request pipeline (request_id, LoRA multiplex,
# batch_output_stream) runs on the decode side.
decode_request = backend.prepare_decode_request(
request=request, peer=None, prefill_response=prefill_chunk
)
# Reuse prefill's ids for this decode so the render skips re-tokenizing.
with _reuse_prompt_token_ids(self._decode_reuse_ids(prefill_chunk)):
local_gen = await getattr(super(), method)(decode_request, raw_request_info)
async for chunk in local_gen:
yield chunk
async def _concurrent_decode(
self,
method: str,
decode_request: RequestType,
prefill_resp: AsyncGenerator,
raw_request_info: Optional[RawRequestInfo],
*,
cancel_on_failure: bool = True,
):
"""Run local decode while a remote prefill drains concurrently.
While prefill is in flight, each decode chunk is raced against the
prefill task so a prefill failure surfaces to the client as an
``ErrorResponse`` (instead of a hung — decode may be waiting on KV that
will never arrive — or seemingly-successful decode stream). The
background prefill task is always awaited so it never leaks on the
prefill/decode engines.
Args:
method: The handle method name ("chat" or "completions").
decode_request: The request to run on the local decode engine.
prefill_resp: The in-flight remote prefill response stream.
raw_request_info: Raw HTTP request info forwarded to the engine.
cancel_on_failure: Whether to cancel the in-flight prefill when
local decode does not complete. Must be False for
``dispatch()``-based prefill (the choose_replica path): its
completion accounting fires when the response completes, so the
stream must be drained to exhaustion, never abandoned. Prefill
is clamped to a single token, so draining is bounded either way.
"""
prefill_task = asyncio.ensure_future(_drain_prefill(prefill_resp))
completed = False
local_gen = None
next_fut = None
try:
local_gen = await getattr(super(), method)(decode_request, raw_request_info)
gen = local_gen.__aiter__()
while True:
# Surface a failed prefill as soon as it is observed.
if prefill_task.done() and isinstance(
prefill_task.result(), ErrorResponse
):
err = prefill_task.result()
logger.error("Remote prefill returned error: %s", err)
yield err
return
if next_fut is None:
next_fut = asyncio.ensure_future(gen.__anext__())
# Race the next decode chunk against the in-flight prefill;
# once prefill has completed (successfully), just stream.
awaitables = {next_fut}
if not prefill_task.done():
awaitables.add(prefill_task)
done, _ = await asyncio.wait(
awaitables, return_when=asyncio.FIRST_COMPLETED
)
if next_fut in done:
try:
chunk = next_fut.result()
except StopAsyncIteration:
break
next_fut = None
yield chunk
# else: prefill finished first; loop back to inspect it.
completed = True
finally:
if next_fut is not None and not next_fut.done():
next_fut.cancel()
with contextlib.suppress(BaseException):
await next_fut
if not completed:
# Abort the local decode request if we bailed early.
if local_gen is not None:
with contextlib.suppress(BaseException):
await local_gen.aclose()
if cancel_on_failure:
prefill_task.cancel()
try:
err = await prefill_task
if isinstance(err, ErrorResponse):
logger.error("Remote prefill returned error: %s", err)
except asyncio.CancelledError:
pass
except Exception as exc: # pragma: no cover - defensive
logger.error("Remote prefill failed: %s", exc)
# ---- Direct-streaming ASGI app ----
async def __serve_build_asgi_app__(self):
"""Serve direct-streaming HTTP through P/D orchestration.
Start from the engine-native app (same as non-P/D direct streaming)
and re-point only ``/v1/chat/completions`` and ``/v1/completions`` at
this server's ``chat`` / ``completions``, which run remote prefill
then local decode. All other routes stay engine-native.
"""
app = await super().__serve_build_asgi_app__()
_strip_routes(app, "/v1/chat/completions")
_strip_routes(app, "/v1/completions")
@app.post("/v1/chat/completions")
async def _pd_chat(body: ChatCompletionRequest, request: Request):
body = _sanitize_chat_completion_request(body)
raw_info = RawRequestInfo.from_starlette_request(request)
return await _pd_http_response(await self.chat(body, raw_info))
@app.post("/v1/completions")
async def _pd_completions(body: CompletionRequest, request: Request):
raw_info = RawRequestInfo.from_starlette_request(request)
return await _pd_http_response(await self.completions(body, raw_info))
return app
# ---- Pre-warm ----
#
# KV transfer connectors (e.g. NIXL) require a handshake between each
# prefill and decode replica before real traffic can flow. Pre-warming
# sends a tiny dummy request through the full prefill->decode path for
# every prefill replica so that the connector establishes its connections
# eagerly at startup rather than on the first user request.
# Enable via: experimental_configs={"_prewarm_prefill_decode": True}
def _make_dummy_request(self, model_id: str) -> CompletionRequest:
"""Build the smallest valid completion request for pre-warm."""
return CompletionRequest(
model=model_id,
prompt=_PREWARM_PROMPT,
max_tokens=_PREWARM_MAX_TOKENS,
stream=False,
request_id=f"prewarm-{uuid.uuid4()}",
)
async def _maybe_prewarm(self) -> None:
"""Run one prefill->decode round-trip per P replica to complete
the connector handshake on both sides before traffic arrives."""
prewarm_enabled = getattr(
self, "_llm_config", None
) and self._llm_config.experimental_configs.get("_prewarm_prefill_decode")
if not prewarm_enabled:
return
logger.info("[PDDecodeServer] Starting pre-warm across all P replicas.")
backend = self._get_connector_backend()
if backend.requires_peer_binding:
# Peer-binding connectors (e.g. MoRIIO) shape a prefill request
# against a specific selected replica's metadata; a peerless
# broadcast prewarm cannot bind one. The connector handshake
# completes on the first real request instead.
logger.info(
"[PDDecodeServer] Skipping pre-warm: connector %s requires peer "
"binding (handshake completes on the first real request).",
type(backend).__name__,
)
return
model_id = self._llm_config.model_id
dummy = self._make_dummy_request(model_id)
prefill_req = backend.prepare_prefill_request(request=dummy, peer=None)
# Broadcast to every live P replica; retry until they are up.
kv_params_list: List[Any] = []
attempt = 0
while attempt < _PREWARM_MAX_RETRIES:
attempt += 1
try:
kv_params_list = await asyncio.get_event_loop().run_in_executor(
None,
lambda: broadcast(
self._prefill_handle,
method_name="prewarm_prefill",
args=[prefill_req],
),
)
break
except DeploymentUnavailableError:
logger.info(
"[PDDecodeServer] PrefillServer not available yet "
"(attempt %d/%d); retrying in %.0fs...",
attempt,
_PREWARM_MAX_RETRIES,
_PREWARM_RETRY_INTERVAL_S,
)
await asyncio.sleep(_PREWARM_RETRY_INTERVAL_S)
except Exception as exc:
logger.warning(
"[PDDecodeServer] broadcast() attempt %d/%d failed; "
"retrying in %.0fs...",
attempt,
_PREWARM_MAX_RETRIES,
_PREWARM_RETRY_INTERVAL_S,
exc_info=exc,
)
await asyncio.sleep(_PREWARM_RETRY_INTERVAL_S)
else:
raise RuntimeError(
f"[PDDecodeServer] Pre-warm failed after {_PREWARM_MAX_RETRIES} "
f"attempts ({_PREWARM_MAX_RETRIES * _PREWARM_RETRY_INTERVAL_S:.0f}s). "
f"PrefillServer may be permanently unavailable."
)
logger.info(
"[PDDecodeServer] broadcast() reached %d P replica(s); "
"driving local decode to complete the handshake.",
len(kv_params_list),
)
# Build one decode request per P replica result.
decode_reqs: List[CompletionRequest] = []
for idx, kv_params in enumerate(kv_params_list):
if not kv_params:
logger.warning(
"[PDDecodeServer] P replica %d returned empty kv_params; skipping.",
idx,
)
continue
req = dummy.model_copy(deep=True)
req.kv_transfer_params = kv_params
decode_reqs.append(req)
# Run all decode requests on the local engine concurrently to trigger
# the connector handshake on D side for each P replica.
async def _decode_one(req: CompletionRequest, idx: int) -> None:
async for _ in self.engine.completions(req, None):
pass
logger.info(
"[PDDecodeServer] Pre-warm handshake done for P replica %d.", idx
)
await asyncio.gather(*[_decode_one(r, i) for i, r in enumerate(decode_reqs)])
logger.info("[PDDecodeServer] Pre-warm complete — all P replicas registered.")
async def _drain_prefill(prefill_resp) -> Optional[ErrorResponse]:
"""Consume a concurrent-handoff prefill response to completion.
In concurrent (e.g. WRITE-mode) handoff the remote prefill produces no useful
tokens — it only needs to run so the connector pushes/registers the KV. We
drain it so the response is fully awaited before the ``choose_replica``
context (if any) exits. Returns an ``ErrorResponse`` if one is observed.
Handles both streaming (``DeploymentResponseGenerator``) and non-streaming
(single ``DeploymentResponse``) results.
"""
try:
async for chunk in prefill_resp:
if isinstance(chunk, ErrorResponse):
return chunk
except TypeError:
result = await prefill_resp
if isinstance(result, ErrorResponse):
return result
return None
# ---------------------------------------------------------------------------
# PDPrefillServer
# ---------------------------------------------------------------------------
class PDPrefillServer(LLMServer):
"""Prefill-side LLM server for P/D disaggregation.
This is a standard LLMServer with an additional ``prewarm_prefill``
method used during the pre-warm handshake.
"""
async def record_replica_metadata(self) -> Dict[str, Any]:
"""Publish this prefill replica's connector coordination metadata.
Read by the decode orchestrator via the replica-metadata hook
(``ReplicaSelection.replica_metadata``) so peer-binding connectors (e.g.
MoRIIO) can address the selected prefill replica. Returns ``{}`` for
connectors that publish nothing (the ``BaseConnectorBackend`` default).
Returns the metadata of the backend that engine init
(``setup_engine_backend``) created, ``setup()``-ed, and stored on this
server's ``_llm_config``. The replica-metadata hook is captured after
engine init, so for connector deployments the backend is present by
then; with no backend stored there is nothing to publish.
"""
backend = getattr(self._llm_config, "kv_connector_backend", None)
if backend is None:
return {}
return backend.replica_metadata()
async def prewarm_prefill(
self, prefill_request: CompletionRequest
) -> Optional[dict]:
"""Run one prefill pass and return kv_transfer_params as a dict.
Returns None on error.
"""
async for chunk in self.engine.completions(prefill_request, None):
if hasattr(chunk, "kv_transfer_params") and chunk.kv_transfer_params:
return chunk.kv_transfer_params
if isinstance(chunk, ErrorResponse):
logger.warning("[PDPrefillServer] prewarm_prefill got error: %s", chunk)
return None
return None
# ---------------------------------------------------------------------------
# PDDecodeServer
# ---------------------------------------------------------------------------
class PDDecodeServer(PDOrchestratorMixin, LLMServer):
"""Decode-side LLM server that orchestrates remote prefill.
This deployment owns a real engine (decode config) and holds a handle
to the prefill deployment. For chat / completions it runs remote
prefill first, then local decode.
"""
async def __init__(
self,
llm_config: LLMConfig,
*,
prefill_server: DeploymentHandle,
engine_cls=None,
model_downloader=None,
):
self._prefill_handle = prefill_server.options(stream=True)
await super().__init__(
llm_config,
engine_cls=engine_cls,
model_downloader=model_downloader,
)
# Active only if enabled and the renderer wrap installs. The `and`
# short-circuits so install() is not called when disabled.
self._pd_tokenize_once = (
bool(self._llm_config.experimental_configs.get("pd_tokenize_once"))
and _install_tokenize_once()
)
await self._maybe_prewarm()
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
return self._pd_handle_request(request, raw_request_info)
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
return self._pd_handle_request(request, raw_request_info)
# ---------------------------------------------------------------------------
# DP + PD combined servers
# ---------------------------------------------------------------------------
class DPPDPrefillServer(PDPrefillServer, DPServer):
"""PDPrefillServer with data-parallel gang scheduling.
MRO: DPPDPrefillServer -> PDPrefillServer -> DPServer -> LLMServer
- get_deployment_options comes from DPServer (adds gang scheduling).
- __init__ falls through to DPServer (DP master info, bundle indices)
then LLMServer (engine setup).
"""
pass
class DPPDDecodeServer(PDDecodeServer, DPServer):
"""PDDecodeServer with data-parallel gang scheduling.
MRO: DPPDDecodeServer -> PDDecodeServer -> PDOrchestratorMixin
-> DPServer -> LLMServer
- get_deployment_options comes from DPServer (adds gang scheduling).
- __init__ from PDDecodeServer sets _prefill_handle, then super().__init__
flows through DPServer (DP setup) then LLMServer (engine setup).
"""
pass
# ---------------------------------------------------------------------------
# Deprecated: PDProxyServer
# TODO(Kourosh): Deprecate, remove in Ray 2.58.
# ---------------------------------------------------------------------------
class PDProxyServer(LLMServerProtocol):
"""Proxy between P/D LLM servers.
.. deprecated::
``PDProxyServer`` is deprecated. Use ``PDDecodeServer`` instead.
This class will be removed in a future release.
"""
async def __init__(
self,
prefill_server: DeploymentHandle,
decode_server: DeploymentHandle,
):
warnings.warn(
"PDProxyServer is deprecated and will be removed in Ray 2.58. "
"Use PDDecodeServer (decode orchestrator) and PDPrefillServer instead.",
DeprecationWarning,
stacklevel=2,
)
self._llm_config = await prefill_server.llm_config.remote()
self.prefill_server = prefill_server.options(stream=True)
self.decode_server = decode_server.options(stream=True)
async def start(self) -> None:
pass
async def check_health(self) -> None:
pass
async def reset_prefix_cache(self) -> None:
raise NotImplementedError(
"reset_prefix_cache is not supported for P/D disaggregation"
)
async def start_profile(self) -> None:
raise NotImplementedError(
"start_profile is not supported for P/D disaggregation"
)
async def stop_profile(self) -> None:
raise NotImplementedError(
"stop_profile is not supported for P/D disaggregation"
)
async def llm_config(self) -> Optional[LLMConfig]:
return self._llm_config
def _prepare_prefill_request(self, request: RequestType) -> RequestType:
assert (
getattr(request, "kv_transfer_params", None) is None
), "kv_transfer_params should be empty before proxy"
prefill_request = request.model_copy(deep=True)
prefill_request.kv_transfer_params = {
"do_remote_decode": True,
"do_remote_prefill": False,
"remote_engine_id": None,
"remote_block_ids": None,
"remote_host": None,
"remote_port": None,
}
prefill_request.max_tokens = 1
prefill_request.stream = False
return prefill_request
def _prepare_decode_request(
self,
request: RequestType,
prefill_chunk: Union[ChatCompletionResponse, CompletionResponse],
) -> RequestType:
decode_request = request.model_copy(deep=True)
decode_request.kv_transfer_params = prefill_chunk.kv_transfer_params
return decode_request
def _maybe_add_request_id_to_request(
self,
request: Union[ChatCompletionRequest, CompletionRequest],
) -> None:
request_id = get_serve_request_id()
if request_id:
request.request_id = request_id
async def _handle_request(
self,
request: RequestType,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[str, ChatCompletionResponse, CompletionResponse, ErrorResponse], None
]:
self._maybe_add_request_id_to_request(request)
if isinstance(request, ChatCompletionRequest):
method = "chat"
elif isinstance(request, CompletionRequest):
method = "completions"
else:
raise ValueError(f"Unsupported request type: {type(request)}")
prefill_request = self._prepare_prefill_request(request)
prefill_gen = getattr(self.prefill_server, method).remote(
prefill_request, raw_request_info
)
prefill_chunk = await prefill_gen.__anext__()
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
decode_request = self._prepare_decode_request(request, prefill_chunk)
decode_gen = getattr(self.decode_server, method).remote(
decode_request, raw_request_info
)
async for chunk in decode_gen:
yield chunk
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
return self._handle_request(request, raw_request_info)
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
return self._handle_request(request, raw_request_info)
async def embeddings(
self,
request: EmbeddingRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[EmbeddingResponse, None]:
raise NotImplementedError("Embedding is not supported for P/D disaggregation")
@classmethod
def get_deployment_options(
cls, prefill_config: "LLMConfig", decode_config: "LLMConfig"
) -> Dict[str, Any]:
return DEFAULT_PD_PROXY_SERVER_OPTIONS