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,3 @@
from ray.llm._internal.serve.engines.sglang.sglang_engine import SGLangServer
__all__ = ["SGLangServer"]
@@ -0,0 +1,773 @@
"""SGLang engine integration for Ray Serve LLM.
Provides ``SGLangServer``, a custom server class that wraps SGLang's
in-process engine and exposes chat, completions, embeddings, tokenize,
and detokenize endpoints through the standard Ray Serve LLM protocol.
Community SGLang support is in early development. Track progress and
provide feedback at https://github.com/ray-project/ray/issues/61114.
"""
import copy
import json
import signal
import time
import uuid
from typing import (
Any,
AsyncGenerator,
List,
Literal,
Optional,
Union,
)
from pydantic import BaseModel
from ray.llm._internal.serve.constants import ENABLE_WORKER_PROCESS_SETUP_HOOK
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
DetokenizeRequest,
DetokenizeResponse,
EmbeddingCompletionRequest,
EmbeddingRequest,
EmbeddingResponse,
TokenizeCompletionRequest,
TokenizeRequest,
TokenizeResponse,
)
from ray.llm._internal.serve.core.protocol import RawRequestInfo
from ray.llm._internal.serve.core.server.llm_server import (
_merge_replica_actor_and_child_actor_bundles,
)
class SGLangPauseConfig(BaseModel):
"""SGLang-specific configuration for pause operation."""
mode: Literal["abort", "in_place", "retract"] = "abort"
"""Pause mode:
- "abort" (default): Terminate all in-flight requests immediately.
- "in_place": Freeze requests in queue, preserve kv cache.
- "retract": Freeze requests in queue, free corresponding KV cache.
"""
class SGLangSleepConfig(BaseModel):
"""SGLang-specific configuration for sleep operation"""
tags: Optional[List[Literal["kv_cache", "weights", "cuda_graph"]]] = None
"""Sleep tags:
- "kv_cache": Discard KV cache
- "weights": Offload to CPU RAM
- "cuda_graph": Discard CUDA graph
- None: Discard/Offload everything
"""
class SGLangWakeupConfig(BaseModel):
"""SGLang-specific configuration for wakeup operation"""
tags: Optional[List[Literal["kv_cache", "weights", "cuda_graph"]]] = None
"""Optional tags to selectively wake up components:
- "kv_cache": Restore KV cache only
- "weights": Restore weights only
- "cuda_graph": Restore CUDA graph only
- None: Restore everything
"""
_SLEEP_TAGS: frozenset[str] = frozenset({"kv_cache", "weights", "cuda_graph"})
class SGLangServer:
def __init__(self, llm_config: LLMConfig):
self._llm_config = llm_config
self.engine_kwargs = llm_config.engine_kwargs
self._is_paused = False
self._sleeping_tags: set[str] = set()
try:
import sglang
except ImportError as e:
raise ImportError(
"SGLang is not installed or failed to import. Please run "
"`pip install sglang[all]` to install required dependencies."
) from e
# TODO(issue-61108): remove this once sglang#18752 is merged and included
# in the minimum supported SGLang version for this example.
original_signal_func = signal.signal
def noop_signal_handler(sig, action):
# Returns default handler to satisfy signal.signal() return signature
return signal.SIG_DFL
try:
# Override signal.signal with our no-op function
signal.signal = noop_signal_handler
self.engine = sglang.Engine(**self.engine_kwargs)
finally:
signal.signal = original_signal_func
@staticmethod
def _build_sampling_params(request: Any) -> dict[str, Any]:
sampling_params: dict[str, Any] = {}
model_fields_set = getattr(request, "model_fields_set", None)
has_model_fields_set = model_fields_set is not None
fields_set = set(model_fields_set) if has_model_fields_set else set()
def was_explicitly_set(field_name: str) -> bool:
# Use model_fields_set when available to avoid injecting defaults for
# fields omitted by the caller.
if has_model_fields_set:
return field_name in fields_set
return getattr(request, field_name, None) is not None
temperature = getattr(request, "temperature", None)
top_p = getattr(request, "top_p", None)
max_tokens = getattr(request, "max_tokens", None)
stop = getattr(request, "stop", None)
if was_explicitly_set("temperature") and temperature is not None:
sampling_params["temperature"] = temperature
if was_explicitly_set("top_p") and top_p is not None:
sampling_params["top_p"] = top_p
if was_explicitly_set("max_tokens") and max_tokens is not None:
sampling_params["max_new_tokens"] = max_tokens
if was_explicitly_set("stop") and stop is not None:
sampling_params["stop"] = stop
return sampling_params
@staticmethod
def _parse_finish_reason(finish_reason_info: Any) -> str:
"""Parse finish_reason from SGLang metadata."""
if isinstance(finish_reason_info, dict):
return finish_reason_info.get("type", "length")
return str(finish_reason_info)
@staticmethod
def _build_chat_messages(messages: List[Any]) -> List[dict[str, Any]]:
converted_messages: List[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
message_dict = dict(message)
elif hasattr(message, "model_dump") and callable(message.model_dump):
message_dict = dict(message.model_dump())
else:
message_dict = {
"role": getattr(message, "role", "user"),
"content": getattr(message, "content", ""),
}
message_dict["role"] = str(message_dict.get("role", "user"))
converted_messages.append(message_dict)
return converted_messages
@staticmethod
def _build_chat_template_kwargs(request: Any) -> dict[str, Any]:
"""
Build optional chat-template kwargs using request fields when present.
This mirrors SGLang's chat-serving pipeline semantics without directly
coupling to its internal server classes.
Works with both ChatCompletionRequest and TokenizeChatRequest since
both expose tools and chat_template_kwargs fields.
"""
kwargs: dict[str, Any] = {}
tools = getattr(request, "tools", None)
if tools is not None:
kwargs["tools"] = tools
reasoning_effort = getattr(request, "reasoning_effort", None)
if reasoning_effort is not None:
kwargs["reasoning_effort"] = reasoning_effort
chat_template_kwargs = getattr(request, "chat_template_kwargs", None)
if isinstance(chat_template_kwargs, dict):
kwargs.update(chat_template_kwargs)
return kwargs
def _render_chat_prompt(
self,
messages: List[dict[str, Any]],
add_generation_prompt: bool = True,
template_kwargs: Optional[dict[str, Any]] = None,
) -> str:
tokenizer = self.engine.tokenizer_manager.tokenizer
# SGLang supports --skip-tokenizer-init, where tokenizer is intentionally
# None and text prompt rendering is not available.
if tokenizer is None:
return self._render_fallback_prompt(
messages, add_generation_prompt=add_generation_prompt
)
return tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=add_generation_prompt,
**(template_kwargs or {}),
)
@staticmethod
def _render_fallback_prompt(
messages: List[dict[str, Any]],
add_generation_prompt: bool = True,
) -> str:
# Fallback prompt format for tokenizers without chat-template support.
prompt_lines: List[str] = []
for message in messages:
role = str(message.get("role", "user"))
content = message.get("content", "")
if content is None:
content = ""
prompt_lines.append(f"{role}: {content}")
if add_generation_prompt:
prompt_lines.append("assistant:")
return "\n".join(prompt_lines)
async def start(self) -> None:
# Engine is initialized in __init__; keep start idempotent for protocol
# compatibility.
return
async def check_health(self) -> None:
# SGLang's in-process Engine API does not expose a health-check method.
# Its health endpoints exist only in HTTP/gRPC server entrypoints, which
# this integration does not run. Keep the protocol hook as a no-op.
return
def _build_generate_kwargs(
self, request: Any, prompt: Any, stream: bool
) -> dict[str, Any]:
"""Build kwargs dict for engine.async_generate."""
generate_kwargs: dict[str, Any] = {
"prompt": prompt,
"stream": stream,
}
sampling_params = self._build_sampling_params(request)
if sampling_params:
generate_kwargs["sampling_params"] = sampling_params
return generate_kwargs
async def _generate_raw(
self,
request: Any,
prompt: Any,
) -> dict[str, Any]:
"""Run generation and return raw engine output payload."""
generate_kwargs = self._build_generate_kwargs(request, prompt, stream=False)
return await self.engine.async_generate(**generate_kwargs)
@staticmethod
def _extract_generation_metadata(raw: dict[str, Any]) -> dict[str, Any]:
"""Extract normalized generation metadata from one raw engine payload."""
text: str = raw.get("text", "")
meta: dict[str, Any] = raw.get("meta_info", {}) or {}
finish_reason_info = meta.get("finish_reason", {}) or {}
finish_reason = SGLangServer._parse_finish_reason(finish_reason_info)
prompt_tokens = int(meta.get("prompt_tokens", 0))
completion_tokens = int(meta.get("completion_tokens", 0))
total_tokens = prompt_tokens + completion_tokens
return {
"text": text.strip(),
"id": meta.get("id", f"sglang-gen-{uuid.uuid4().hex}"),
"created": int(time.time()),
"finish_reason": finish_reason,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
async def _generate_and_extract_metadata(
self,
request: Any,
prompt: Union[str, List[str]],
) -> Union[dict[str, Any], List[dict[str, Any]]]:
"""
Handles parameter extraction, calls the SGLang engine, and processes the
raw response to extract common metadata and generated text.
Accepts either a single prompt string or a list of prompts. When a list
is provided, all prompts are sent to SGLang in one batched call, letting
SGLang's scheduler handle concurrency natively via async_generate.
"""
raw = await self._generate_raw(request, prompt)
# Batch case — SGLang returns a list of results, one per prompt
if isinstance(prompt, list):
if not raw:
raise RuntimeError(
"SGLang engine returned an empty response list during generation."
)
return [self._extract_generation_metadata(r) for r in raw]
# Single prompt case
if isinstance(raw, list):
if not raw:
raise RuntimeError(
"SGLang engine returned an empty response list during generation."
)
raw = raw[0]
return self._extract_generation_metadata(raw)
async def _stream_generate(
self,
request: Any,
prompt: Any,
) -> AsyncGenerator[tuple[str, Optional[str]], None]:
"""Stream from SGLang engine, yielding (delta_text, finish_reason) tuples.
SGLang returns cumulative text in each chunk, so this method
tracks the previous text and yields only the incremental delta.
"""
generate_kwargs = self._build_generate_kwargs(request, prompt, stream=True)
stream = await self.engine.async_generate(**generate_kwargs)
previous_text = ""
async for chunk in stream:
text = chunk.get("text", "")
meta = chunk.get("meta_info", {}) or {}
delta_text = text[len(previous_text) :]
previous_text = text
finish_reason_info = meta.get("finish_reason", None)
finish_reason = (
self._parse_finish_reason(finish_reason_info)
if finish_reason_info is not None
else None
)
yield delta_text, finish_reason
@staticmethod
def _build_sse_chunk(
gen_id: str,
object_type: str,
created: int,
model: str,
choice: dict[str, Any],
) -> str:
"""Build an SSE-formatted chunk string from a single choice payload."""
chunk_data = {
"id": gen_id,
"object": object_type,
"created": created,
"model": model,
"choices": [choice],
}
return f"data: {json.dumps(chunk_data)}\n\n"
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse], None]:
chat_messages = self._build_chat_messages(request.messages)
template_kwargs = self._build_chat_template_kwargs(request)
prompt = self._render_chat_prompt(
chat_messages, template_kwargs=template_kwargs
)
if request.stream:
gen_id = f"sglang-gen-{uuid.uuid4().hex}"
created = int(time.time())
first_chunk = True
async for delta_text, finish_reason in self._stream_generate(
request, prompt
):
delta: dict[str, Any] = {"content": delta_text}
if first_chunk:
delta["role"] = "assistant"
first_chunk = False
yield self._build_sse_chunk(
gen_id,
"chat.completion.chunk",
created,
request.model,
{"index": 0, "delta": delta, "finish_reason": finish_reason},
)
return
metadata = await self._generate_and_extract_metadata(request, prompt)
usage_data = {
"prompt_tokens": metadata["prompt_tokens"],
"completion_tokens": metadata["completion_tokens"],
"total_tokens": metadata["total_tokens"],
}
choice_data = {
"index": 0,
"message": {"role": "assistant", "content": metadata["text"]},
"finish_reason": metadata["finish_reason"],
}
resp = ChatCompletionResponse(
id=metadata["id"],
object="chat.completion",
created=metadata["created"],
model=request.model,
choices=[choice_data],
usage=usage_data,
)
yield resp
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse], None]:
prompt_input = request.prompt
# Normalize prompt input.
if isinstance(prompt_input, list):
if not prompt_input:
raise ValueError(
"The 'prompt' list cannot be empty for completion requests."
)
prompts_to_process = prompt_input
else:
prompts_to_process = [prompt_input]
if request.stream:
gen_id = f"sglang-gen-{uuid.uuid4().hex}"
created = int(time.time())
for i, prompt_string in enumerate(prompts_to_process):
async for delta_text, finish_reason in self._stream_generate(
request, prompt_string
):
yield self._build_sse_chunk(
gen_id,
"text_completion",
created,
request.model,
{
"index": i,
"text": delta_text,
"logprobs": None,
"finish_reason": finish_reason,
},
)
return
results = await self._generate_and_extract_metadata(request, prompts_to_process)
all_choices = []
total_prompt_tokens = 0
total_completion_tokens = 0
for index, metadata in enumerate(results):
total_prompt_tokens += metadata["prompt_tokens"]
total_completion_tokens += metadata["completion_tokens"]
choice_data = {
"index": index,
"text": metadata["text"],
"logprobs": None,
"finish_reason": metadata["finish_reason"],
}
all_choices.append(choice_data)
usage_data = {
"prompt_tokens": total_prompt_tokens,
"completion_tokens": total_completion_tokens,
"total_tokens": total_prompt_tokens + total_completion_tokens,
}
last_metadata = results[-1]
resp = CompletionResponse(
id=last_metadata["id"],
object="text_completion",
created=last_metadata.get("created", int(time.time())),
model=getattr(request, "model", "default_model"),
choices=all_choices,
usage=usage_data,
)
yield resp
async def embeddings(
self,
request: EmbeddingRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[EmbeddingResponse, None]:
# Input handling follows SGLang's OpenAIServingEmbedding pattern:
# https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/entrypoints/openai/serving_embedding.py
if isinstance(request, EmbeddingCompletionRequest):
prompt = request.input
else:
# Chat embedding request - join messages without the trailing
# "assistant:" generation cue that _render_fallback_prompt adds.
chat_messages = self._build_chat_messages(request.messages)
prompt = "\n".join(
f"{m.get('role', 'user')}: {m.get('content') or ''}"
for m in chat_messages
)
# async_encode handles both single strings and lists of strings
results = await self.engine.async_encode(prompt)
if not isinstance(results, list):
results = [results]
if not results:
raise RuntimeError(
"SGLang engine returned an empty response for embedding request."
)
# Build response following SGLang's _build_embedding_response pattern
data = []
total_prompt_tokens = 0
for idx, ret_item in enumerate(results):
data.append(
{
"index": idx,
"object": "embedding",
"embedding": ret_item.get("embedding", []),
}
)
meta = ret_item.get("meta_info", {}) or {}
total_prompt_tokens += int(meta.get("prompt_tokens", 0))
resp = EmbeddingResponse(
object="list",
model=request.model or "",
data=data,
usage={
"prompt_tokens": total_prompt_tokens,
"total_tokens": total_prompt_tokens,
"completion_tokens": 0,
},
)
yield resp
async def tokenize(
self,
request: TokenizeRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[TokenizeResponse, None]:
tokenizer = self.engine.tokenizer_manager.tokenizer
if tokenizer is None:
raise RuntimeError(
"Tokenizer is not available. The tokenize endpoint is not "
"supported when SGLang is initialized with --skip-tokenizer-init."
)
if isinstance(request, TokenizeCompletionRequest):
prompt = request.prompt
else:
# Chat tokenize request - render messages to prompt string
chat_messages = self._build_chat_messages(request.messages)
add_generation_prompt = getattr(request, "add_generation_prompt", True)
template_kwargs = self._build_chat_template_kwargs(request)
prompt = self._render_chat_prompt(
chat_messages,
add_generation_prompt=add_generation_prompt,
template_kwargs=template_kwargs,
)
add_special_tokens = getattr(request, "add_special_tokens", True)
tokens = tokenizer.encode(prompt, add_special_tokens=add_special_tokens)
max_model_len = (
getattr(self.engine.tokenizer_manager, "context_len", None)
or getattr(self.engine.server_args, "context_length", None)
or 0
)
yield TokenizeResponse(
tokens=tokens,
count=len(tokens),
max_model_len=max_model_len,
)
async def detokenize(
self,
request: DetokenizeRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[DetokenizeResponse, None]:
tokenizer = self.engine.tokenizer_manager.tokenizer
if tokenizer is None:
raise RuntimeError(
"Tokenizer is not available. The detokenize endpoint is not "
"supported when SGLang is initialized with --skip-tokenizer-init."
)
prompt = tokenizer.decode(request.tokens)
yield DetokenizeResponse(text=prompt)
async def llm_config(self) -> Optional[LLMConfig]:
return self._llm_config
@classmethod
def get_deployment_options(cls, llm_config: "LLMConfig"):
deployment_options = copy.deepcopy(llm_config.deployment_config)
pg_config = llm_config.placement_group_config or {}
ray_actor_options = deployment_options.get("ray_actor_options", {})
tp_size = llm_config.engine_kwargs.get("tp_size", 1)
pp_size = llm_config.engine_kwargs.get("pp_size", 1)
num_devices = tp_size * pp_size
if tp_size < 1 or pp_size < 1:
raise ValueError(
f"Invalid configuration: tp_size={tp_size} and pp_size={pp_size}. "
f"Both must be >= 1."
)
if "placement_group_bundles" not in pg_config:
child_bundles = [{"GPU": 1} for _ in range(num_devices)]
replica_bundle = {
"CPU": ray_actor_options.get("num_cpus", 1),
}
if ray_actor_options.get("num_gpus"):
replica_bundle["GPU"] = ray_actor_options["num_gpus"]
replica_bundle.update(ray_actor_options.get("resources", {}))
if "memory" in ray_actor_options:
replica_bundle["memory"] = ray_actor_options["memory"]
pg_bundles = _merge_replica_actor_and_child_actor_bundles(
child_actor_bundles=child_bundles,
replica_actor_bundle=replica_bundle,
)
pg_strategy = "PACK"
else:
pg_bundles = pg_config.get("placement_group_bundles")
pg_strategy = pg_config.get("placement_group_strategy", "PACK")
deployment_options.update(
{
"placement_group_bundles": pg_bundles,
"placement_group_strategy": pg_strategy,
}
)
runtime_env = ray_actor_options.setdefault("runtime_env", {})
if ENABLE_WORKER_PROCESS_SETUP_HOOK:
runtime_env.setdefault(
"worker_process_setup_hook",
"ray.llm._internal.serve._worker_process_setup_hook",
)
if llm_config.runtime_env:
runtime_env.update(llm_config.runtime_env)
deployment_options["ray_actor_options"] = ray_actor_options
return deployment_options
async def pause(self, **kwargs: Any) -> None:
"""Pause generation on the SGlang server
This halts generation/encoding requests while keeping model weights in GPU memory. New requests are blocked until resume is called.
Args:
**kwargs: Options parsed into SGLangPauseConfig.
- mode (str): "abort" (default), "in_place", or "retract"
"""
assert self.engine is not None, "server is not initialized"
config = SGLangPauseConfig(**kwargs)
from sglang.srt.managers.io_struct import PauseGenerationReqInput
await self.engine.tokenizer_manager.pause_generation(
PauseGenerationReqInput(mode=config.mode)
)
self._is_paused = True
async def resume(self, **kwargs: Any) -> None:
"""Resume generation on the SGLang server after pause.
Args:
**kwargs: Reserved for future options.
"""
assert self.engine is not None, "server is not initialized"
from sglang.srt.managers.io_struct import ContinueGenerationReqInput
await self.engine.tokenizer_manager.continue_generation(
ContinueGenerationReqInput()
)
self._is_paused = False
async def is_paused(self) -> bool:
"""Check whether the SGLang server is currently paused.
Returns:
True if the server is paused, False otherwise.
"""
return self._is_paused
async def sleep(self, **kwargs: Any) -> None:
"""Put SGLang server to sleep.
Args:
**kwargs: Options parsed into SGLangSleepConfig
- tags (List[str], optional): Components to put to sleep.
"""
assert self.engine is not None, "server is not initialized"
config = SGLangSleepConfig(**kwargs)
# release_memory_occupation() calls loop.run_until_complete() internally, which fails
# inside an async context. Await the underlying coroutine directly.
from sglang.srt.entrypoints.engine import ReleaseMemoryOccupationReqInput
obj = ReleaseMemoryOccupationReqInput(tags=config.tags)
await self.engine.tokenizer_manager.release_memory_occupation(obj, None)
self._sleeping_tags |= set(config.tags) if config.tags else set(_SLEEP_TAGS)
async def wakeup(self, **kwargs: Any) -> None:
"""Wake up the SGLang server from sleep mode.
Args:
**kwargs: Options parsed into SGLangWakeupConfig
- tags (List[str], optional): Components to wake up.
"""
assert self.engine is not None, "server is not initialized"
config = SGLangWakeupConfig(**kwargs)
# resume_memory_occupation() release_memory_occupation() calls loop.run_until_complete() internally, which fails
# inside an async context. Await the underlying coroutine directly.
from sglang.srt.entrypoints.engine import ResumeMemoryOccupationReqInput
obj = ResumeMemoryOccupationReqInput(tags=config.tags)
await self.engine.tokenizer_manager.resume_memory_occupation(obj, None)
if config.tags is None:
self._sleeping_tags.clear()
else:
self._sleeping_tags -= set(config.tags)
async def is_sleeping(self) -> bool:
"""Check whether the SGLang server is currently sleeping.
Returns:
True if any component is currently offloaded/discarded, False otherwise.
"""
return bool(self._sleeping_tags)
async def reset_prefix_cache(self, timeout: Optional[float] = None) -> None:
assert self.engine is not None, "server is not initialized"
# flush_cache() calls loop.run_until_complete() internally, which fails
# inside an async context. Await the underlying coroutine directly.
await self.engine.tokenizer_manager.flush_cache()
@@ -0,0 +1,5 @@
"""KV Transfer connector backends for Ray Serve LLM.
This package provides connector backends for KV cache transfer in vLLM.
All backends are lazily loaded through the factory to avoid circular imports.
"""
@@ -0,0 +1,264 @@
import abc
import random
import string
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from ray import serve
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
CompletionRequest,
)
# The two OpenAI request models the P/D orchestrator shapes. Defined under
# TYPE_CHECKING (and used as a string annotation) to avoid an import cycle
# between this module and the config/openai-models modules.
RequestType = Union[ChatCompletionRequest, CompletionRequest]
def base_prefill_kv_transfer_params() -> Dict[str, Any]:
"""The ``kv_transfer_params`` common to a prefill (producer) request.
Tells the prefill engine to produce KV for a remote decode. Connectors layer
their own keys (e.g. a transfer id, DP/TP routing) on top of these.
"""
return {
"do_remote_decode": True,
"do_remote_prefill": False,
"remote_engine_id": None,
"remote_block_ids": None,
}
def clamp_request_to_single_token(request: "RequestType") -> None:
"""Clamp a prefill request to a single, non-streaming token (in place)."""
request.max_tokens = 1
if hasattr(request, "max_completion_tokens"):
request.max_completion_tokens = 1
request.stream = False
if hasattr(request, "stream_options"):
request.stream_options = None
class BaseConnectorBackend(abc.ABC):
# ---- P/D coordination protocol ----
#
# These class attributes and methods let the P/D orchestrator
# (``PDOrchestratorMixin``) delegate request shaping, peer addressing, and
# handoff discipline to the connector. They are connector-agnostic: a
# connector picks a quadrant of (``requires_peer_binding``,
# ``concurrent_handoff``) and implements ``prepare_prefill_request`` /
# ``prepare_decode_request`` accordingly.
#
# ``requires_peer_binding``:
# * False -> the orchestrator dispatches prefill via the standard handle
# path; the peer (if any) is resolved post-hoc from the prefill response.
# * True -> the orchestrator selects the prefill replica first
# (``choose_replica``) and passes its ``replica_metadata`` to the backend
# as ``peer`` (pre-dispatch addressing).
#
# ``concurrent_handoff``:
# * False -> prefill runs to its first chunk before local decode starts
# (sequential handoff).
# * True -> prefill dispatch and local decode run concurrently.
#
# The two flags are independent; the known combos:
# * (False, False) — e.g. NixlConnector / LMCacheConnectorV1: decode learns
# everything it needs (remote engine id / block ids) from the prefill
# response, so it must wait for that response (sequential).
# * (True, True) — e.g. MoRIIO WRITE mode: the peer address is bound
# into the request id before dispatch and prefill *pushes* KV to decode,
# so decode needs nothing from prefill's response and can start
# immediately (concurrent). Concurrent handoff is only possible because
# peer binding happens up front.
# * (True, False) — e.g. MoRIIO READ mode: the peer is bound up front,
# but decode *pulls* KV using block ids returned in prefill's response,
# so it still waits for prefill to finish (sequential).
requires_peer_binding: bool = False
concurrent_handoff: bool = False
def __init__(self, llm_config: "LLMConfig"):
"""Base class for connector backends.
Args:
llm_config: The llm configuration for this engine
"""
self.llm_config = llm_config
@property
def kv_transfer_config(self) -> Dict[str, Any]:
engine_kwargs = self.llm_config.engine_kwargs
kv_transfer_config = engine_kwargs.get("kv_transfer_config")
assert (
kv_transfer_config is not None
), "In Connector backend, kv_transfer_config is not set"
return kv_transfer_config
def _get_unique_suffix(self, len: int = 6) -> str:
"""Generates unique alphanumeric suffix.
Args:
len: Length of the suffix to generate.
Returns:
A unique alphanumeric suffix string of specified length.
"""
return "".join(random.choices(string.ascii_letters + string.digits, k=len))
def _compute_port_offset(self) -> int:
"""Compute a deterministic port offset for this replica.
Uses data_parallel_rank if DP case, otherwise falls back to
the replica rank assigned by Ray Serve (TP/PP case).
For TP/PP cases, multiply by num_devices (tp × pp) to reserve
sufficient port space, since each worker needs a unique port.
Each TP worker adds its tp_rank (0, 1, ..., tp_size-1) to the
base port at bind time, and PP stages also need separate ports.
Returns:
Non-negative integer offset to add to a base port.
"""
# Prefer explicit DP rank when available
dp_rank = self.llm_config.engine_kwargs.get("data_parallel_rank")
if isinstance(dp_rank, int) and dp_rank >= 0:
# vLLM already accounts for TP spacing in DP offset calculation
# (data_parallel_rank × tp_size), don't multiply here
return dp_rank
# NOTE (jeffreywang): A missing replica context must fail loudly, not
# silently return a 0 offset that collides colocated replicas on the
# same NIXL side-channel port. get_replica_context() raises RayServeException
# outside a replica.
rc = serve.get_replica_context()
engine_config = self.llm_config.get_engine_config()
num_devices = engine_config.num_devices
return rc.rank.rank * num_devices
@abc.abstractmethod
def prepare_prefill_request(
self, *, request: "RequestType", peer: Optional[Dict[str, Any]]
) -> "RequestType":
"""Shape the request sent to the remote prefill engine.
Args:
request: The incoming chat/completion request.
peer: The selected prefill replica's ``replica_metadata`` dict when
the connector opted into pre-dispatch peer binding
(``requires_peer_binding=True``), else None.
Returns:
A new request object to dispatch to the prefill engine.
"""
...
@abc.abstractmethod
def prepare_decode_request(
self,
*,
request: "RequestType",
peer: Optional[Dict[str, Any]],
prefill_response: Optional[Any],
) -> "RequestType":
"""Shape the request run on the local decode engine.
Args:
request: The incoming chat/completion request.
peer: The selected prefill replica's ``replica_metadata`` dict when
the connector opted into pre-dispatch peer binding, else None.
prefill_response: The captured prefill response chunk whose
``kv_transfer_params`` may be forwarded, or None when no chunk is
captured before decode starts (concurrent-handoff mode).
Returns:
A new request object to run on the local decode engine.
"""
...
def setup(self) -> None:
"""Setup the connector backend.
This method is called to setup the connector backend.
"""
pass
def replica_metadata(self) -> Dict[str, Any]:
"""Static per-replica coordination data published to the orchestrator.
Surfaced via the replica-metadata hook on ``ReplicaSelection`` so that a
connector opting into ``requires_peer_binding`` can address the selected
prefill peer. The default backend publishes nothing; connectors that need
to advertise an address (e.g. MoRIIO's zmq endpoint) override this.
Returns:
A JSON-serializable dict of per-replica metadata (empty by default).
"""
return {}
class DefaultPDProtocolMixin:
"""The default P/D protocol policy: no peer binding, sequential handoff.
Implements ``prepare_prefill_request`` / ``prepare_decode_request`` for
connectors that follow the standard policy: the prefill engine is told to
produce KV for a remote decode (clamped to a single non-streaming token),
and the decode engine forwards the ``kv_transfer_params`` that the prefill
engine returned on its first response chunk.
Mix this in *before* ``BaseConnectorBackend`` in a backend's bases so its
concrete methods satisfy the abstract methods.
"""
def prepare_prefill_request(
self, *, request: "RequestType", peer: Optional[Dict[str, Any]]
) -> "RequestType":
"""Shape the prefill request under the default P/D protocol policy.
Deep-copies the request, stamps the standard ``kv_transfer_params`` that
tell the prefill engine to produce KV for a remote decode, and clamps it
to a single, non-streaming token. ``peer`` is ignored.
"""
assert (
getattr(request, "kv_transfer_params", None) is None
), "kv_transfer_params should be empty before orchestrator"
prefill_request = request.model_copy(deep=True)
prefill_request.kv_transfer_params = {
**base_prefill_kv_transfer_params(),
"remote_host": None,
"remote_port": None,
}
clamp_request_to_single_token(prefill_request)
return prefill_request
def prepare_decode_request(
self,
*,
request: "RequestType",
peer: Optional[Dict[str, Any]],
prefill_response: Optional[Any],
) -> "RequestType":
"""Shape the decode request under the default P/D protocol policy.
Deep-copies the request and, only when a prefill response chunk was
captured, forwards its ``kv_transfer_params`` so the decode engine
pulls/receives the KV produced by prefill. In concurrent-handoff mode
``prefill_response`` is None and the request is left unmodified. ``peer``
is ignored.
"""
decode_request = request.model_copy(deep=True)
if prefill_response is not None:
decode_request.kv_transfer_params = prefill_response.kv_transfer_params
return decode_request
class DefaultConnectorBackend(DefaultPDProtocolMixin, BaseConnectorBackend):
"""Concrete connector backend using the default P/D protocol policy.
Used as the factory fallback for connectors that are not registered with a
dedicated backend class: they get a no-op ``setup()`` and the default
request-shaping policy. ``BaseConnectorBackend`` is abstract, so the factory
must return a concrete class like this one.
"""
pass
@@ -0,0 +1,140 @@
"""Factory for lazy-loading KV connector backends.
This module provides a factory pattern for registering and instantiating
KV connector backends without eagerly importing all implementations.
This avoids circular import issues and improves startup performance.
"""
from typing import TYPE_CHECKING, Type, Union
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
DefaultConnectorBackend,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.utils.registry import get_registry
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
logger = get_logger(__name__)
# Get the registry instance for KV connector backends
_kv_backend_registry = get_registry("kv_connector_backend")
class KVConnectorBackendFactory:
"""Factory for creating KV connector backend instances with lazy loading."""
@classmethod
def register_backend(
cls,
name: str,
backend_class_or_path: Union[Type["BaseConnectorBackend"], str],
) -> None:
"""Register a connector backend.
This enables the backend to be accessed on every Ray process in the cluster.
Args:
name: The name of the connector (e.g., "LMCacheConnectorV1")
backend_class_or_path: Either:
- The backend class object directly (preferred), or
- A string in the format "module_path:class_name" for lazy loading
Examples:
# Register with class directly (recommended):
KVConnectorBackendFactory.register_backend("MyConnector", MyConnectorClass)
# Register with module path string (for lazy loading):
KVConnectorBackendFactory.register_backend("MyConnector", "my.module:MyClass")
"""
_kv_backend_registry.register(name, backend_class_or_path)
@classmethod
def get_backend_class(cls, name: str) -> Type["BaseConnectorBackend"]:
"""Get the connector backend class by name.
For registered connectors, returns the registered backend class.
For unregistered connectors, returns DefaultConnectorBackend (a concrete
backend with a no-op setup() and the default P/D protocol policy),
allowing connectors that don't require Ray Serve orchestration to work
without registration. (BaseConnectorBackend itself is abstract and
cannot be instantiated.)
Args:
name: The name of the connector backend
Returns:
The connector backend class
Raises:
ImportError: If a registered backend fails to load
"""
try:
return _kv_backend_registry.get(name)
except ValueError:
logger.warning(
f"Unsupported connector backend: {name}. "
f"Using default: {DefaultConnectorBackend.__name__}."
)
return DefaultConnectorBackend
except Exception as e:
raise ImportError(
f"Failed to load connector backend '{name}': {type(e).__name__}: {e}"
) from e
@classmethod
def create_backend(
cls, name: str, llm_config: "LLMConfig"
) -> "BaseConnectorBackend":
"""Create a connector backend instance.
Args:
name: The name of the connector backend
llm_config: The LLM configuration
Returns:
An instance of the connector backend
"""
return cls.get_backend_class(name)(llm_config)
@classmethod
def is_registered(cls, name: str) -> bool:
"""Check if a connector backend is registered."""
return _kv_backend_registry.contains(name)
@classmethod
def unregister_backend(cls, name: str) -> None:
"""Unregister a connector backend.
Removes the backend from the registry across all Ray processes.
Args:
name: The name of the connector backend to unregister
"""
_kv_backend_registry.unregister(name)
BUILTIN_BACKENDS = {
"LMCacheConnectorV1": "ray.llm._internal.serve.engines.vllm.kv_transfer.lmcache:LMCacheConnectorV1Backend",
"NixlConnector": "ray.llm._internal.serve.engines.vllm.kv_transfer.nixl:NixlConnectorBackend",
"MultiConnector": "ray.llm._internal.serve.engines.vllm.kv_transfer.multi_connector:MultiConnectorBackend",
"MoRIIOConnector": "ray.llm._internal.serve.engines.vllm.kv_transfer.moriio:MoRIIOConnectorBackend",
}
def _initialize_registry() -> None:
"""Initialize the registry with built-in backends.
This function is called when the module is imported to ensure
built-in backends are registered.
"""
for name, backend_path in BUILTIN_BACKENDS.items():
if not KVConnectorBackendFactory.is_registered(name):
KVConnectorBackendFactory.register_backend(name, backend_path)
# Initialize registry when module is imported
_initialize_registry()
@@ -0,0 +1,62 @@
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
DefaultPDProtocolMixin,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
def _check_lmcache_installed():
try:
import lmcache # noqa: F401
except ImportError:
raise ImportError(
"LMCache is not installed. Please install it with `pip install lmcache`."
)
class LMCacheConnectorV1Backend(DefaultPDProtocolMixin, BaseConnectorBackend):
KV_CONNECTOR_EXTRA_CONFIG_FIELD_NAME = "kv_connector_extra_config"
LMCACHE_RPC_PORT_FIELD_NAME = "lmcache_rpc_port"
DEFAULT_LMCACHE_RPC_PORT_NAME = "lmcache_rpc_port"
def setup(self) -> None:
"""Initialize the LMCache connector backend.
Creates a unique LMCache RPC port name across replicas by appending
a random suffix to the base port name.
Raises:
ImportError: If LMCache is not installed.
"""
_check_lmcache_installed()
if (
LMCacheConnectorV1Backend.KV_CONNECTOR_EXTRA_CONFIG_FIELD_NAME
not in self.kv_transfer_config
):
return
kv_connector_extra_config = self.kv_transfer_config[
LMCacheConnectorV1Backend.KV_CONNECTOR_EXTRA_CONFIG_FIELD_NAME
]
base_value = kv_connector_extra_config.get(
LMCacheConnectorV1Backend.LMCACHE_RPC_PORT_FIELD_NAME,
LMCacheConnectorV1Backend.DEFAULT_LMCACHE_RPC_PORT_NAME,
)
# Append random suffix for uniqueness
lmcache_rpc_port_value = str(base_value) + self._get_unique_suffix()
if (
LMCacheConnectorV1Backend.LMCACHE_RPC_PORT_FIELD_NAME
in kv_connector_extra_config
):
logger.info(
f"Setting unique lmcache_rpc_port={lmcache_rpc_port_value} for current replica."
)
kv_connector_extra_config[
LMCacheConnectorV1Backend.LMCACHE_RPC_PORT_FIELD_NAME
] = lmcache_rpc_port_value
@@ -0,0 +1,344 @@
"""MoRIIO connector backend for Ray Serve LLM (analogue of nixl.py).
Configures a vLLM engine's ``kv_transfer_config.kv_connector_extra_config`` for
the MoRIIO connector and computes per-replica handshake/notify ports so colocated
replicas don't collide. Also builds the engine's advertised zmq address so the
P/D orchestrator can discover it via the replica-metadata hook
(``ReplicaSelection.replica_metadata``), and implements the PD connector protocol
(``requires_peer_binding`` / ``concurrent_handoff`` / ``prepare_prefill_request`` /
``prepare_decode_request``) so the decode orchestrator can address the selected
prefill peer by request id.
Unlike NIXL/LMCache, MoRIIO does NOT use ``DefaultPDProtocolMixin``: it has custom
request shaping (a dual-address request_id + transfer_id) and therefore IMPLEMENTS
the abstract ``prepare_*`` methods directly on ``BaseConnectorBackend``.
Two transfer disciplines, selected by ``read_mode``:
* WRITE (default): prefill PUSHES KV to decode -> concurrent handoff.
* READ: decode PULLS KV from prefill -> sequential handoff; the decode request
forwards the ``remote_block_ids`` / ``remote_engine_id`` the prefill engine
returned.
The dual-address request_id and the transfer_id are derived DETERMINISTICALLY
from the incoming request id (a hash), so ``prepare_prefill_request`` and
``prepare_decode_request`` produce identical ids across their two separate calls
without per-request backend state (the backend instance is shared across
requests).
Registered with Ray's public connector registry via the factory.
"""
import hashlib
import logging
import re
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
import ray
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
base_prefill_kv_transfer_params,
clamp_request_to_single_token,
)
if TYPE_CHECKING:
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import RequestType
logger = logging.getLogger(__name__)
# Defaults mirror vLLM's MoRIIOConstants (DEFAULT_HANDSHAKE_PORT / NOTIFY_PORT).
# Prefill uses these bases; decode is shifted (see builder.py) so a colocated
# P+D pair on one node doesn't collide.
DEFAULT_HANDSHAKE_PORT_BASE = 6301
DEFAULT_NOTIFY_PORT_BASE = 61005
# experimental_configs keys understood by this backend.
HANDSHAKE_PORT_BASE_KEY = "MORI_HANDSHAKE_PORT_BASE"
NOTIFY_PORT_BASE_KEY = "MORI_NOTIFY_PORT_BASE"
# ---------------------------------------------------------------------------
# Dual-address request_id / zmq address encoding.
#
# These MUST stay byte-compatible with the regexes vLLM's MoRIIO connector uses
# to recover peer addresses from the request_id:
#
# vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_common.py
# _PREFILL_ZMQ_RE = re.compile(r"___prefill_addr_(.+?)___decode_addr_")
# _DECODE_ZMQ_RE = re.compile(r"___decode_addr_(.+)_[0-9a-f]{32}(?:-.*)?$")
# # zmq address: "host:IP,handshake:PORT,notify:PORT"
# ---------------------------------------------------------------------------
_PREFILL_PREFIX = "___prefill_addr_"
_DECODE_PREFIX = "___decode_addr_"
_TRANSFER_PREFIX = "tx"
# Copies of vLLM's regexes for local validation / round-trip tests.
_PREFILL_ZMQ_RE = re.compile(r"___prefill_addr_(.+?)___decode_addr_")
_DECODE_ZMQ_RE = re.compile(r"___decode_addr_(.+)_[0-9a-f]{32}(?:-.*)?$")
def build_zmq_address(host: str, handshake_port: int, notify_port: int) -> str:
"""Build the MORI zmq address string ``host:IP,handshake:PORT,notify:PORT``."""
return f"host:{host},handshake:{handshake_port},notify:{notify_port}"
def parse_zmq_address(zmq_address: str) -> Tuple[str, int, int]:
"""Inverse of :func:`build_zmq_address` -> ``(host, handshake_port, notify_port)``."""
parts = {}
for segment in zmq_address.split(","):
key, _, val = segment.partition(":")
parts[key.strip()] = val.strip()
return parts["host"], int(parts["handshake"]), int(parts["notify"])
def parse_peer_zmq(request_id: str, is_producer: bool) -> str:
"""Recover the peer's zmq address from a request id (for tests/debugging).
Producer (prefill) wants the *decode* address; consumer wants the *prefill*.
"""
rex = _DECODE_ZMQ_RE if is_producer else _PREFILL_ZMQ_RE
m = rex.search(request_id)
if not m:
raise ValueError(f"No peer zmq address in request_id: {request_id!r}")
return m.group(1)
def _read_mode_enabled(extra_config: Dict[str, Any]) -> bool:
"""Mirror vLLM's ``get_moriio_mode`` parse of ``read_mode``.
true / 1 -> READ; anything else -> WRITE (default).
"""
return str(extra_config.get("read_mode", "false")).lower().strip() in (
"true",
"1",
)
class MoRIIOConnectorBackend(BaseConnectorBackend):
"""Set up MoRIIO ports/extra_config and implement the PD connector protocol."""
# The advertised zmq address ("host:IP,handshake:PORT,notify:PORT"),
# computed by setup(); consumers reach it via this backend instance.
_zmq_address: Optional[str] = None
# MORI addresses peers by the dual-address request id, so the orchestrator
# must bind to the selected prefill replica BEFORE dispatch.
requires_peer_binding: bool = True
def _extra_config(self) -> dict:
cfg = self.kv_transfer_config.setdefault("kv_connector_extra_config", {})
return cfg
@property
def _read_mode(self) -> bool:
"""True iff this engine's MoRIIO connector is configured for READ mode."""
extra = self._extra_config()
return _read_mode_enabled(extra)
@property
def concurrent_handoff(self) -> bool:
"""WRITE -> concurrent (prefill pushes); READ -> sequential (decode pulls)."""
return not self._read_mode
def setup(self) -> None:
offset = self._compute_port_offset()
handshake_base = int(
self.llm_config.experimental_configs.get(
HANDSHAKE_PORT_BASE_KEY, DEFAULT_HANDSHAKE_PORT_BASE
)
)
notify_base = int(
self.llm_config.experimental_configs.get(
NOTIFY_PORT_BASE_KEY, DEFAULT_NOTIFY_PORT_BASE
)
)
# NOTE: vLLM internally adds get_port_offset(dp_rank, tp_rank) on top of
# these bases. For TP/DP>1, reserve a stride >= tp_size*pp_size when
# shifting decode's base in the builder so the two offset schemes never
# overlap.
handshake_port = handshake_base + offset
notify_port = notify_base + offset
extra = self._extra_config()
# Required keys for vLLM's config parser (KeyError otherwise) -- proxyless.
extra.setdefault("proxy_ip", "") # empty => ping/registration thread disabled
extra.setdefault("proxy_ping_port", "0")
# TODO: real Serve replica HTTP port. Harmless placeholder while
# proxy_ip="" (only used to build request_address for the disabled ping).
extra.setdefault("http_port", str(8000 + offset))
# WRITE mode (prefill pushes). READ would be "true".
extra.setdefault("read_mode", "false")
extra["handshake_port"] = str(handshake_port)
extra["notify_port"] = str(notify_port)
# Advertise the Ray internal cluster IP as the zmq host.
host = ray.util.get_node_ip_address()
zmq_address = build_zmq_address(host, handshake_port, notify_port)
# Stash so replica_metadata() can publish it; the decode
# orchestrator reads the selected prefill replica's copy off the peer.
self._zmq_address = zmq_address
# Cross-node correctness: vLLM's MoRIIO worker otherwise binds/advertises
# get_ip(), which on a Ray cluster is the node's unroutable public IP, and
# VLLM_HOST_IP cannot be propagated to workers (it is excluded from vLLM's
# driver->worker env copy). Pass the routable node IP through the connector
# config, which does reach the workers; vLLM's MoRIIO connector honors
# "host_ip" over get_ip(). Requires vllm-project/vllm#45488.
extra["host_ip"] = host
# ---- parallelism (data/tensor) ----
def _dp_rank(self) -> int:
rank = self.llm_config.engine_kwargs.get("data_parallel_rank")
return rank if isinstance(rank, int) and rank >= 0 else 0
def _dp_size(self) -> int:
return int(self.llm_config.engine_kwargs.get("data_parallel_size") or 1)
def _tp_size(self) -> int:
return int(self.llm_config.engine_kwargs.get("tensor_parallel_size") or 1)
# ---- replica metadata (published via the replica-metadata hook) ----
def replica_metadata(self) -> dict:
"""Static per-replica coordination data published to the orchestrator.
The prefill replica publishes its MORI zmq address and its parallelism
(DP rank/size, TP size); the decode orchestrator reads them off the
selected prefill replica's ``ReplicaSelection.replica_metadata`` and uses
them to address the right remote (dp_rank, tp) workers.
"""
return {
"mori_zmq_address": self._zmq_address,
"dp_rank": self._dp_rank(),
"dp_size": self._dp_size(),
"tp_size": self._tp_size(),
}
def _remote_routing(self, remote: Dict[str, Any]) -> Dict[str, Any]:
"""``kv_transfer_params`` keys telling vLLM which remote workers to reach.
``remote`` is the metadata of the *other* side of the transfer: the
decode (this orchestrator) for a prefill request, the selected prefill
peer for a decode request. vLLM addresses a remote worker at
``advertised_base + get_port_offset(remote_dp_rank, tp_index)`` and
handshakes all ``remote_dp_size`` ranks, so both must match the target
replica. ``tp_size`` is the remote's TP (symmetric across P/D).
"""
return {
"remote_dp_rank": int(remote.get("dp_rank", 0)),
"remote_dp_size": int(remote.get("dp_size", 1)),
"tp_size": int(remote.get("tp_size", self._tp_size())),
}
def _own_routing(self) -> Dict[str, Any]:
"""``_remote_routing`` input describing this (decode orchestrator) replica
-- the remote side for a prefill request."""
return {
"dp_rank": self._dp_rank(),
"dp_size": self._dp_size(),
"tp_size": self._tp_size(),
}
# ---- request shaping (PD connector protocol) ----
def _dual_ids(
self, request: Any, peer: Optional[Dict[str, Any]]
) -> Tuple[str, str]:
"""Compute the (dual-address request_id, transfer_id) for this request.
``prepare_prefill_request`` and ``prepare_decode_request`` are two
independent, stateless calls for the same request, so both ids are
derived deterministically (hash of a stable per-request seed) — no
per-request backend state.
"""
prefill_zmq = (peer or {}).get("mori_zmq_address")
decode_zmq = self._zmq_address
if not prefill_zmq:
raise ValueError(
"MoRIIO peer is missing 'mori_zmq_address': the selected prefill "
"replica did not publish its address (is MoRIIOConnector "
"configured on the prefill deployment?)."
)
if not decode_zmq:
raise ValueError(
"MoRIIO decode zmq address is not set: setup() must run on this "
"engine before requests are shaped."
)
# The incoming request_id (always populated -- OpenAI models default it
# to a uuid) is the seed. Both prepare_* calls run on the same request
# object, so they agree; uniqueness per request is inherited from it.
seed = str(request.request_id)
# 32 hex chars (the trailing uid _PREFILL_ZMQ_RE / _DECODE_ZMQ_RE
# anchor on); a hash of the seed, so both prepare_* calls agree.
uid = hashlib.sha256(seed.encode()).hexdigest()[:32]
# Wire format consumed by vLLM's MoRIIO connector.
request_id = f"{_PREFILL_PREFIX}{prefill_zmq}{_DECODE_PREFIX}{decode_zmq}_{uid}"
transfer_id = f"{_TRANSFER_PREFIX}-{uid}"
return request_id, transfer_id
def prepare_prefill_request(
self, *, request: "RequestType", peer: Optional[Dict[str, Any]]
) -> "RequestType":
request_id, transfer_id = self._dual_ids(request, peer)
prefill_request = request.model_copy(deep=True)
# The dual-address id (peer zmq encoded in it) must reach the engine:
# setting request_id explicitly makes the LLMServer pipeline preserve it
# (not clobber it with the Serve id) and the engine copies it into the
# X-Request-Id header that vLLM's MoRIIO connector parses.
prefill_request.request_id = request_id
prefill_request.kv_transfer_params = {
**base_prefill_kv_transfer_params(),
"transfer_id": transfer_id,
# The prefill engine's remote is the decode (this orchestrator).
**self._remote_routing(self._own_routing()),
}
clamp_request_to_single_token(prefill_request)
return prefill_request
def prepare_decode_request(
self,
*,
request: "RequestType",
peer: Optional[Dict[str, Any]],
prefill_response: Optional[Any],
) -> "RequestType":
request_id, transfer_id = self._dual_ids(request, peer)
decode_request = request.model_copy(deep=True)
decode_request.request_id = request_id
# The decode engine's remote is the selected prefill peer.
remote_routing = self._remote_routing(peer or {})
if not self._read_mode:
# WRITE: prefill pushes KV; decode just needs do_remote_prefill + the
# shared transfer_id (no block ids -- they are pushed, not pulled).
decode_request.kv_transfer_params = {
"do_remote_prefill": True,
"do_remote_decode": False,
"remote_engine_id": None,
"remote_block_ids": None,
"transfer_id": transfer_id,
**remote_routing,
}
return decode_request
# READ: decode PULLS KV; forward the remote_block_ids / remote_engine_id
# the prefill engine returned on its response. If absent (e.g. prompt <
# block_size / full prefix hit), fall back to a local recompute.
prefill_kv_params = getattr(prefill_response, "kv_transfer_params", None)
params = dict(prefill_kv_params) if prefill_kv_params else {}
if params.get("remote_block_ids") and params.get("remote_engine_id"):
params.setdefault("transfer_id", transfer_id)
params["do_remote_prefill"] = True
params["do_remote_decode"] = False
# Address the prefill peer's (dp_rank, dp_size, tp) workers.
params.update(remote_routing)
decode_request.kv_transfer_params = params
else:
logger.warning(
"[MORI][READ] prefill returned no remote_block_ids/remote_engine_id "
"(kv_transfer_params=%s); decode will recompute locally.",
prefill_kv_params,
)
decode_request.kv_transfer_params = None
return decode_request
@@ -0,0 +1,94 @@
import copy
from typing import TYPE_CHECKING, List
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
)
from ray.llm._internal.serve.engines.vllm.kv_transfer.factory import (
KVConnectorBackendFactory,
)
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
class MultiConnectorBackend(BaseConnectorBackend):
"""Wraps multiple sub-connectors.
The P/D protocol (``prepare_prefill_request`` / ``prepare_decode_request`` and
the ``requires_peer_binding`` / ``concurrent_handoff`` policy) is delegated to
the *first* (top-most) sub-connector listed in ``connectors`` — that
connector's policy governs request shaping and handoff for the group. Each
sub-connector's ``setup()`` still runs.
"""
def __init__(self, llm_config: "LLMConfig"):
super().__init__(llm_config)
self._connector_backends: List[BaseConnectorBackend] = []
def setup(self) -> None:
"""Setup all connectors listed in the kv_transfer_config."""
kv_transfer_config = self.kv_transfer_config
connectors = kv_transfer_config.get("kv_connector_extra_config", {}).get(
"connectors", []
)
if not connectors:
# Fail fast at setup rather than with a cryptic error when the
# orchestrator later delegates to a (missing) top-most sub-connector.
raise ValueError(
"MultiConnector requires at least one sub-connector in "
"kv_connector_extra_config.connectors."
)
for connector in connectors:
connector_backend_str = connector.get("kv_connector")
if connector_backend_str is None:
raise ValueError("kv_connector is not set in the connector")
if connector_backend_str == "MultiConnector":
raise ValueError(
"Nesting MultiConnector within MultiConnector is not supported."
)
# Merge parent config with connector-specific config
sub_llm_config = copy.deepcopy(self.llm_config)
sub_llm_config.engine_kwargs["kv_transfer_config"] = {
**{
k: v
for k, v in kv_transfer_config.items()
if k != "kv_connector_extra_config"
},
**connector,
}
# Use factory to get backend class lazily
connector_backend = KVConnectorBackendFactory.create_backend(
connector_backend_str, sub_llm_config
)
connector_backend.setup()
self._connector_backends.append(connector_backend)
@property
def _primary(self) -> BaseConnectorBackend:
"""The top-most sub-connector, whose protocol governs the group."""
if not self._connector_backends:
raise ValueError(
"MultiConnectorBackend has no sub-connectors; was setup() called?"
)
return self._connector_backends[0]
@property
def requires_peer_binding(self) -> bool:
return bool(self._connector_backends) and self._primary.requires_peer_binding
@property
def concurrent_handoff(self) -> bool:
return bool(self._connector_backends) and self._primary.concurrent_handoff
def prepare_prefill_request(self, *, request, peer):
return self._primary.prepare_prefill_request(request=request, peer=peer)
def prepare_decode_request(self, *, request, peer, prefill_response):
return self._primary.prepare_decode_request(
request=request, peer=peer, prefill_response=prefill_response
)
@@ -0,0 +1,67 @@
import os
import ray
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
DefaultPDProtocolMixin,
)
class NixlConnectorBackend(DefaultPDProtocolMixin, BaseConnectorBackend):
def _set_side_channel_port(self):
from vllm import envs as vllm_envs
if vllm_envs.is_set("VLLM_NIXL_SIDE_CHANNEL_PORT"):
return
base_port = int(
self.llm_config.experimental_configs.get(
"NIXL_SIDE_CHANNEL_PORT_BASE", 20000
)
)
port = base_port + self._compute_port_offset()
os.environ["VLLM_NIXL_SIDE_CHANNEL_PORT"] = str(port)
def _set_side_channel_host(self):
from vllm import envs as vllm_envs
if not vllm_envs.is_set("VLLM_NIXL_SIDE_CHANNEL_HOST"):
# Use Ray's node IP (internal/cluster IP) instead of vLLM's
# get_ip() which can return external/public IPs on hostNetwork
# pods, causing cross-node NIXL handshakes to fail.
os.environ["VLLM_NIXL_SIDE_CHANNEL_HOST"] = ray.util.get_node_ip_address()
def setup(self) -> None:
"""Initialize the NIXL connector backend.
This method sets up the NIXL (NVIDIA Inference Xfer Library) connector by:
1. Verifying that the required vLLM environment variables are supported
2. Configuring the side channel port and host if not already set
3. Creating a unique engine ID across replicas
The side channel is used for KV cache transfer between vLLM instances.
Raises:
ValueError: If the current vLLM version doesn't support the required
NIXL environment variables.
"""
from vllm import envs as vllm_envs
if (
"VLLM_NIXL_SIDE_CHANNEL_PORT" not in vllm_envs.environment_variables
or "VLLM_NIXL_SIDE_CHANNEL_HOST" not in vllm_envs.environment_variables
):
raise ValueError(
"This vLLM version does not support VLLM_NIXL_SIDE_CHANNEL_PORT"
"or VLLM_NIXL_SIDE_CHANNEL_HOST environment variable. It's likely"
"that you are using an older version of vLLM."
)
self._set_side_channel_port()
self._set_side_channel_host()
# We need to overwrite the engine_id to make it unique across replicas.
engine_id = self.kv_transfer_config.get("engine_id", self._get_unique_suffix())
host = vllm_envs.VLLM_NIXL_SIDE_CHANNEL_HOST
port = vllm_envs.VLLM_NIXL_SIDE_CHANNEL_PORT
self.kv_transfer_config["engine_id"] = "-".join([engine_id, host, str(port)])
@@ -0,0 +1,960 @@
import argparse
import dataclasses
import inspect
import json
import typing
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
List,
Literal,
Optional,
Tuple,
Union,
)
from pydantic import BaseModel, field_validator
from starlette.datastructures import State
from starlette.requests import Request
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.entrypoints.openai.cli_args import FrontendArgs
from vllm.entrypoints.openai.engine.protocol import ErrorResponse as VLLMErrorResponse
import ray
from ray.llm._internal.common.callbacks.base import CallbackCtx
from ray.llm._internal.common.utils.import_utils import try_import
from ray.llm._internal.serve.core.configs.llm_config import (
DiskMultiplexConfig,
LLMConfig,
)
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
DetokenizeRequest,
DetokenizeResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorInfo,
ErrorResponse,
ScoreRequest,
ScoreResponse,
TokenizeRequest,
TokenizeResponse,
TranscriptionRequest,
TranscriptionResponse,
)
from ray.llm._internal.serve.core.engine.protocol import LLMEngine
from ray.llm._internal.serve.core.protocol import RawRequestInfo
from ray.llm._internal.serve.engines.vllm.vllm_models import (
VLLMEngineConfig,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_router import (
is_kv_aware,
)
from ray.llm._internal.serve.routing_policies.kv_aware.vllm.kv_events import (
assign_replica_kv_events_endpoint,
get_kv_event_routing_stats,
)
from ray.llm._internal.serve.routing_policies.kv_aware.vllm.token_tracking import (
enable_token_tracking,
)
from ray.llm._internal.serve.utils.node_initialization_utils import (
initialize_node,
)
from ray.util.placement_group import PlacementGroup
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
if TYPE_CHECKING:
from vllm.config import VllmConfig
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
from vllm.entrypoints.pooling.embed.serving import ServingEmbedding
from vllm.entrypoints.pooling.scoring.serving import ServingScores
from vllm.entrypoints.serve.tokenize.serving import OpenAIServingTokenization
from vllm.entrypoints.speech_to_text.transcription.serving import (
OpenAIServingTranscription,
)
vllm = try_import("vllm")
logger = get_logger(__name__)
def _canonicalize_request_id_header(
request: Any, raw_request_info: Optional[RawRequestInfo]
) -> Optional[RawRequestInfo]:
"""Ensure raw_request_info carries X-Request-Id == request.request_id so vLLM's
OpenAI layer (which prefers the header) sees the authoritative request id.
Returns a RawRequestInfo (new or mutated) with a single, correctly-cased header.
If the request has no request_id, this is a no-op and returns raw_request_info
unchanged (so embeddings/score/transcription requests are unaffected).
"""
rid = getattr(request, "request_id", None)
if not rid:
return raw_request_info
headers = dict(raw_request_info.headers) if raw_request_info is not None else {}
# Drop any existing variant of the header (case- and separator-insensitive,
# e.g. "X-Request-Id" or "x_request_id") before setting the canonical one.
headers = {
k: v
for k, v in headers.items()
if k.replace("_", "-").lower() != "x-request-id"
}
headers["x-request-id"] = str(rid)
if raw_request_info is None:
return RawRequestInfo(headers=headers)
# Preserve any non-header fields RawRequestInfo carries (now or in the future).
return dataclasses.replace(raw_request_info, headers=headers)
def _convert_config_dicts(merged: dict) -> dict:
"""Convert dict values to their proper vLLM config classes based on type hints.
vLLM's AsyncEngineArgs has fields like structured_outputs_config,
compilation_config, etc. that expect dataclass instances. When users pass
dicts for these fields, we need to convert them to the proper config classes
so that default values are populated correctly.
Without this conversion, dicts get converted to argparse.Namespace objects
which lack the default field values, causing AttributeError when vLLM code
tries to access those fields.
"""
fields_by_name = {f.name: f for f in dataclasses.fields(AsyncEngineArgs)}
for key, value in list(merged.items()):
if not isinstance(value, dict) or key not in fields_by_name:
continue
hint = fields_by_name[key].type
if isinstance(hint, str):
continue
# Handle Optional[X] (Union[X, None]) -> X
origin = typing.get_origin(hint)
if origin is Union:
args = typing.get_args(hint)
hint = next((a for a in args if a is not type(None)), hint)
# Convert dict to dataclass if the field expects a dataclass type
if isinstance(hint, type) and dataclasses.is_dataclass(hint):
try:
merged[key] = hint(**value)
except Exception as e:
logger.warning(
f"Failed to convert {key} dict to {hint.__name__}: {e}. "
"Using dict as-is."
)
return merged
def _dict_to_namespace(obj: Any) -> Any:
"""Recursively converts dictionaries to argparse.Namespace."""
if isinstance(obj, dict):
return argparse.Namespace(**{k: _dict_to_namespace(v) for k, v in obj.items()})
elif isinstance(obj, list):
return [_dict_to_namespace(item) for item in obj]
else:
return obj
def _get_vllm_engine_config(
llm_config: LLMConfig,
) -> Tuple["AsyncEngineArgs", "VllmConfig"]:
engine_config = llm_config.get_engine_config()
# Resolve to local cache path if model was downloaded from S3/GCS mirror
# Only do this if mirror_config was specified (intentional S3/GCS download)
if engine_config.mirror_config:
from ray.llm._internal.common.utils.download_utils import (
get_model_location_on_disk,
)
local_path = get_model_location_on_disk(engine_config.actual_hf_model_id)
if local_path and local_path != engine_config.actual_hf_model_id:
engine_config.hf_model_id = local_path
logger.info(f"Resolved model from mirror to local path: {local_path}")
from vllm.usage.usage_lib import UsageContext
try:
async_engine_args = vllm.engine.arg_utils.AsyncEngineArgs(
**engine_config.get_initialization_kwargs()
)
vllm_engine_config = async_engine_args.create_engine_config(
usage_context=UsageContext.OPENAI_API_SERVER
)
except Exception as e:
# vLLM's ModelConfig is a pydantic dataclass; its ValidationError holds an
# unpicklable ArgsKwargs and cannot cross the Ray task boundary. Re-raise as
# a plain error so the real message propagates instead of a pickling failure.
raise RuntimeError(f"Failed to create vLLM engine config: {e}") from None
return async_engine_args, vllm_engine_config
def _clear_current_platform_cache():
"""Clear the cache of the current platform.
vllm current has an lru cache for getting device compatibility
that will not have the correct returned value if
CUDA_VISIBLE_DEVICES is not set properly. In RayLLM eventually
when we want to create the engine the env will be set properly,
but till then, upon the import of vllm somewhere
(which is a mystery) the lru cache will have the wrong value.
This function will clear the cache so that the next time the
cache is accessed, it will be re-evaluated.
Related issues:
https://github.com/vllm-project/vllm/issues/8402
https://github.com/vllm-project/vllm/issues/7890
"""
from vllm.platforms import current_platform
# This check is just to future proof this implementation
# in case vllm removes their lru_cache decorator
if hasattr(current_platform.get_device_capability, "cache_clear"):
logger.info("Clearing the current platform cache ...")
current_platform.get_device_capability.cache_clear()
class VLLMSleepConfig(BaseModel):
"""vLLM-specific configuration for sleep operation."""
level: int = 1
"""Sleep level:
- Level 1: Offload weights to CPU RAM, discard KV cache
- Level 2: Discard both model weights and KV cache (deeper sleep)
"""
@field_validator("level")
@classmethod
def validate_level(cls, v: Any) -> int:
if v not in (1, 2):
raise ValueError("level must be 1 or 2")
return v
class VLLMWakeupConfig(BaseModel):
"""vLLM-specific configuration for wakeup operation."""
tags: Optional[List[str]] = None
"""Optional tags to selectively wake up components:
- "weights": Restore model weights only
- "kv_cache": Restore KV cache only
- None: Restore everything
"""
@field_validator("tags")
@classmethod
def validate_tags(cls, v: Any) -> Optional[List[str]]:
if v is not None:
valid_tags = {"weights", "kv_cache"}
for tag in v:
if tag not in valid_tags:
raise ValueError(
f"Invalid tag '{tag}'. Must be one of: {valid_tags}"
)
return v
class VLLMPauseConfig(BaseModel):
"""vLLM-specific configuration for pause operation."""
mode: Literal["abort", "wait", "keep"] = "abort"
"""Pause mode:
- "abort" (default): Abort all in-flight requests immediately.
- "wait": Wait for in-flight requests to complete before pausing.
- "keep": Freeze requests in queue; they resume on resume_generation().
"""
clear_cache: bool = True
"""Whether to clear KV and prefix caches after draining.
Set to False to preserve cache for faster resume.
"""
class VLLMEngine(LLMEngine):
def __init__(
self,
llm_config: LLMConfig,
):
"""Create a vLLM Engine class
Args:
llm_config: The llm configuration for this engine
"""
super().__init__(llm_config)
self.llm_config = llm_config
if vllm is None:
raise ImportError(
"vLLM is not installed. Please install it with `pip install ray[llm]`."
)
assign_replica_kv_events_endpoint(self.llm_config)
self.llm_config.setup_engine_backend()
self._running = False
# Routing stats advertised to Serve's request router; populated in
# start() once the engine's KV-events endpoint is bound.
self._routing_stats: Dict[str, Any] = {}
# vLLM Integration points. Will be set through .start()
self._engine_client = None
self._oai_models: Optional["OpenAIServingModels"] = None
self._oai_serving_chat: Optional["OpenAIServingChat"] = None
self._oai_serving_completion: Optional["OpenAIServingCompletion"] = None
self._oai_serving_embedding: Optional["ServingEmbedding"] = None
self._oai_serving_transcription: Optional["OpenAIServingTranscription"] = None
self._oai_serving_scores: Optional["ServingScores"] = None
self._oai_serving_tokenization: Optional["OpenAIServingTokenization"] = None
async def build_asgi_app(self):
from vllm.entrypoints.openai.api_server import build_app, init_app_state
supported_tasks = ("generate",)
if hasattr(self._engine_client, "get_supported_tasks"):
supported_tasks = await self._engine_client.get_supported_tasks()
# Pass model_config so vLLM mounts the pooling routers (/pooling, /classify,
# /embed, /score) on the native ASGI app to enable direct streaming for pooling
# classify, embed, and score.
app = build_app(
self._vllm_args,
supported_tasks=supported_tasks,
model_config=self._engine_client.model_config,
)
await init_app_state(
self._engine_client,
app.state,
self._vllm_args,
supported_tasks=supported_tasks,
)
return app
async def start(self) -> None:
"""Start the vLLM engine.
If the engine is already running, do nothing.
"""
if self._running:
# The engine is already running!
logger.info("Skipping engine restart because the engine is already running")
return
from vllm.entrypoints.openai.api_server import init_app_state
callback = self.llm_config.get_or_create_callback()
await callback.run_callback("on_before_node_init")
if callback.ctx.run_init_node:
await initialize_node(self.llm_config)
await callback.run_callback("on_after_node_init")
(
vllm_engine_args,
vllm_frontend_args,
vllm_engine_config,
) = self._prepare_engine_config(callback.ctx)
# Apply checkpoint info to the llm_config.
# This is needed for capturing model capabilities
# (e.g. supports vision, etc.) on the llm_config.
config = self.llm_config.get_engine_config()
self.llm_config.apply_checkpoint_info(
vllm_engine_config.model_config.model,
trust_remote_code=config.trust_remote_code,
)
self._engine_client = self._start_async_llm_engine(
vllm_engine_args,
vllm_engine_config,
callback.ctx.placement_group,
)
state = State()
# TODO (Kourosh): There might be some variables that needs protection?
merged = vllm_frontend_args.__dict__ | vllm_engine_args.__dict__
# Convert dict values to proper vLLM config classes (e.g., StructuredOutputsConfig)
# so that default field values are populated correctly.
merged = _convert_config_dicts(merged)
args = _dict_to_namespace(merged)
self._vllm_args = args
# Query supported tasks from the engine so init_app_state initializes the correct serving objects.
# Without this, vLLM falls back to 'generate' only.
init_kwargs: dict[str, Any] = dict(
state=state,
args=args,
)
if "supported_tasks" in inspect.signature(init_app_state).parameters:
if hasattr(self._engine_client, "get_supported_tasks"):
supported_tasks = await self._engine_client.get_supported_tasks()
init_kwargs["supported_tasks"] = supported_tasks
if "vllm_config" in inspect.signature(init_app_state).parameters:
init_kwargs["vllm_config"] = vllm_engine_config
await init_app_state(self._engine_client, **init_kwargs)
self._oai_models = getattr(state, "openai_serving_models", None)
self._oai_serving_chat = getattr(state, "openai_serving_chat", None)
self._oai_serving_completion = getattr(state, "openai_serving_completion", None)
self._oai_serving_embedding = getattr(state, "serving_embedding", None)
self._oai_serving_transcription = getattr(
state, "openai_serving_transcription", None
)
self._oai_serving_scores = getattr(state, "serving_scores", None)
self._oai_serving_tokenization = getattr(
state, "openai_serving_tokenization", None
)
self._validate_openai_serving_models()
self._validate_engine_client()
self._routing_stats = get_kv_event_routing_stats(
self.llm_config,
vllm_engine_config.cache_config.block_size,
vllm_engine_config.scheduler_config.max_num_batched_tokens,
)
self._running = True
logger.info("Started vLLM engine.")
def routing_stats(self) -> Dict[str, Any]:
"""Returns KV event and replay endpoints for KV-aware routing."""
return self._routing_stats
def _validate_openai_serving_models(self):
assert self._oai_models is not None, "oai_models is not initialized"
assert hasattr(
self._oai_models, "lora_requests"
), "oai_models must have a lora_requests attribute"
assert hasattr(
self._oai_models, "load_lora_adapter"
), "oai_models must have a load_lora_adapter attribute"
@staticmethod
def _make_error(message: str) -> ErrorResponse:
return ErrorResponse(
error=ErrorInfo(message=message, type="invalid_request_error", code=400)
)
def _validate_openai_serving_chat(self) -> Optional[ErrorResponse]:
if self._oai_serving_chat is None:
return self._make_error(
"This model does not support the 'generate' task. "
"The chat completion endpoint is not available for this model."
)
def _validate_openai_serving_completion(self) -> Optional[ErrorResponse]:
if self._oai_serving_completion is None:
return self._make_error(
"This model does not support the 'generate' task. "
"The completion endpoint is not available for this model."
)
def _validate_openai_serving_embedding(self) -> Optional[ErrorResponse]:
if self._oai_serving_embedding is None:
return self._make_error(
"This model does not support the 'embed' task. "
"The embedding endpoint is not available for this model."
)
def _validate_openai_serving_transcription(self) -> Optional[ErrorResponse]:
if self._oai_serving_transcription is None:
return self._make_error(
"This model does not support the 'transcription' task. "
"The transcription endpoint is not available for this model."
)
def _validate_openai_serving_scores(self) -> Optional[ErrorResponse]:
if self._oai_serving_scores is None:
return self._make_error(
"This model does not support the 'score' task. "
"The score endpoint is not available for this model."
)
def _validate_openai_serving_tokenization(self) -> Optional[ErrorResponse]:
if self._oai_serving_tokenization is None:
return self._make_error(
"This model does not support the 'tokenization' task. "
"The tokenization endpoint is not available for this model."
)
def _validate_engine_client(self):
assert hasattr(
self._engine_client, "check_health"
), "engine_client must have a check_health attribute"
def _prepare_engine_config(
self, callback_ctx: CallbackCtx
) -> Tuple["AsyncEngineArgs", "FrontendArgs", "VllmConfig"]:
"""Prepare the engine config to start the engine.
Args:
callback_ctx: The callback context.
Returns:
A tuple of:
engine_args: The vLLM's internal engine arguments that is flattened.
frontend_args: The vLLM's internal frontend arguments that is flattened.
engine_config: The vLLM's internal engine config that is nested.
"""
engine_config: VLLMEngineConfig = self.llm_config.get_engine_config()
# If the backend is anything other than CPU, we need to create the
# engine config on a task with hardware access.
if engine_config.accelerator.requires_remote_initialization:
accelerator = engine_config.accelerator
accelerator_type = self.llm_config.accelerator_type
# Initialize options required for the remote task and hardware backend
remote_options = {
"num_cpus": 0,
"runtime_env": callback_ctx.runtime_env,
"scheduling_strategy": PlacementGroupSchedulingStrategy(
placement_group=callback_ctx.placement_group,
),
**accelerator.get_remote_options(accelerator_type),
}
ref = (
ray.remote(_get_vllm_engine_config)
.options(**remote_options)
.remote(self.llm_config)
)
vllm_engine_args, vllm_engine_config = ray.get(ref)
else:
vllm_engine_args, vllm_engine_config = _get_vllm_engine_config(
self.llm_config
)
vllm_frontend_args = FrontendArgs(**engine_config.frontend_kwargs)
return vllm_engine_args, vllm_frontend_args, vllm_engine_config
def _start_async_llm_engine(
self,
vllm_engine_args: "AsyncEngineArgs",
vllm_engine_config: "VllmConfig",
placement_group: PlacementGroup,
) -> "EngineClient":
"""Creates an async LLM engine from the engine arguments."""
from vllm.v1.engine.async_llm import AsyncLLM
from vllm.v1.executor.abstract import Executor
vllm_engine_config.parallel_config.placement_group = placement_group
_clear_current_platform_cache()
custom_stat_loggers = None
if self.llm_config.log_engine_metrics:
from vllm.v1.metrics.ray_wrappers import RayPrometheusStatLogger
# V1 AsyncLLM does not yet support add_logger: https://github.com/vllm-project/vllm/issues/17702
# Use `disable_log_stats: False` and `log_engine_metrics: False` as
# a workaround to enable PrometheusStatLogger instead.
custom_stat_loggers = [RayPrometheusStatLogger]
executor_class = Executor.get_class(vllm_engine_config)
logger.info(f"Using executor class: {executor_class}")
# Report per-request token progress to the deployment's KV router actor,
# but only on KV-aware deployments: elsewhere the actor never exists and
# resolving it per request would block the engine's event loop.
engine_cls = AsyncLLM
if is_kv_aware(self.llm_config):
engine_cls = enable_token_tracking(AsyncLLM)
engine_client = engine_cls(
vllm_config=vllm_engine_config,
executor_class=executor_class,
log_requests=vllm_engine_args.enable_log_requests,
log_stats=not vllm_engine_args.disable_log_stats,
stat_loggers=custom_stat_loggers,
)
return engine_client
async def resolve_lora(self, disk_lora_model: DiskMultiplexConfig):
from vllm.entrypoints.serve.lora.protocol import LoadLoRAAdapterRequest
self._validate_openai_serving_models()
if disk_lora_model.model_id in self._oai_models.lora_requests:
# Lora is already loaded, return
return
lora_request = await self._oai_models.load_lora_adapter( # type: ignore[attr-defined]
request=LoadLoRAAdapterRequest(
lora_name=disk_lora_model.model_id,
lora_path=disk_lora_model.local_path,
)
)
if isinstance(lora_request, VLLMErrorResponse):
raise ValueError(f"Failed to load lora model: {lora_request.error.message}")
@staticmethod
def _make_error_response(
serving: Any,
exc: Exception,
) -> ErrorResponse:
"""Convert an exception to an ErrorResponse and map exception types to
the appropriate HTTP status codes (e.g. VLLMValidationError -> 400).
"""
try:
vllm_error = serving.create_error_response(exc)
return ErrorResponse(error=ErrorInfo(**vllm_error.error.model_dump()))
except Exception:
raise exc # re-raise the original so it surfaces as a 500
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_chat():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
chat_response = await self._oai_serving_chat.create_chat_completion( # type: ignore[attr-defined]
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_chat, e)
return
if isinstance(chat_response, AsyncGenerator):
async for response in chat_response:
if not isinstance(response, str):
raise ValueError(
f"Expected create_chat_completion to return a stream of strings, got an item with type {type(response)}"
)
yield response
else:
if isinstance(chat_response, VLLMErrorResponse):
yield ErrorResponse(error=ErrorInfo(**chat_response.error.model_dump()))
else:
yield ChatCompletionResponse(**chat_response.model_dump())
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_completion():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
completion_response = await self._oai_serving_completion.create_completion( # type: ignore[attr-defined]
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_completion, e)
return
if isinstance(completion_response, AsyncGenerator):
async for response in completion_response:
if not isinstance(response, str):
raise ValueError(
f"Expected create_completion to return a stream of strings, got an item with type {type(response)}"
)
yield response
else:
if isinstance(completion_response, VLLMErrorResponse):
yield ErrorResponse(
error=ErrorInfo(**completion_response.error.model_dump())
)
else:
yield CompletionResponse(**completion_response.model_dump())
async def embeddings(
self,
request: EmbeddingRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[EmbeddingResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_embedding():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
embedding_response = await self._oai_serving_embedding(
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_embedding, e)
return
# vLLM 0.18+ returns a starlette Response object
content = json.loads(embedding_response.body)
yield EmbeddingResponse(**content)
async def transcriptions(
self,
request: TranscriptionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, TranscriptionResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_transcription():
yield error
return
# Extract audio data from the request file
audio_data = await request.file.read()
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
transcription_response = await self._oai_serving_transcription.create_transcription( # type: ignore[attr-defined]
audio_data,
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_transcription, e)
return
if isinstance(transcription_response, AsyncGenerator):
async for response in transcription_response:
if not isinstance(response, str):
raise ValueError(
f"Expected create_transcription to return a stream of strings, got an item with type {type(response)}"
)
yield response
else:
if isinstance(transcription_response, VLLMErrorResponse):
yield ErrorResponse(
error=ErrorInfo(**transcription_response.error.model_dump())
)
else:
yield TranscriptionResponse(**transcription_response.model_dump())
async def score(
self,
request: ScoreRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[ScoreResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_scores():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
assert self._oai_serving_scores is not None
score_response = await self._oai_serving_scores(
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_scores, e)
return
content = json.loads(score_response.body)
yield ScoreResponse(**content)
async def tokenize(
self,
request: TokenizeRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[TokenizeResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_tokenization():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
tokenize_response = await self._oai_serving_tokenization.create_tokenize(
request,
raw_request=raw_request,
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_tokenization, e)
return
if isinstance(tokenize_response, VLLMErrorResponse):
yield ErrorResponse(error=ErrorInfo(**tokenize_response.error.model_dump()))
else:
yield TokenizeResponse(**tokenize_response.model_dump())
async def detokenize(
self,
request: DetokenizeRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[DetokenizeResponse, ErrorResponse], None]:
if error := self._validate_openai_serving_tokenization():
yield error
return
raw_request_info = _canonicalize_request_id_header(request, raw_request_info)
raw_request: Optional[Request] = RawRequestInfo.to_starlette_request_optional(
raw_request_info
)
try:
detokenize_response = (
await self._oai_serving_tokenization.create_detokenize(
request,
raw_request=raw_request,
)
)
except ValueError as e:
yield self._make_error_response(self._oai_serving_tokenization, e)
return
if isinstance(detokenize_response, VLLMErrorResponse):
yield ErrorResponse(
error=ErrorInfo(**detokenize_response.error.model_dump())
)
else:
yield DetokenizeResponse(**detokenize_response.model_dump())
async def check_health(self) -> None:
assert self._engine_client is not None, "engine_client is not initialized"
try:
await self._engine_client.check_health()
except BaseException as e:
logger.error("Healthcheck failed. The replica will be restarted")
raise e from None
async def reset_prefix_cache(self) -> None:
assert self._engine_client is not None, "engine_client is not initialized"
await self._engine_client.reset_prefix_cache()
async def sleep(self, **kwargs: Any) -> None:
"""Put the vLLM engine to sleep.
Args:
**kwargs: Options parsed into VLLMSleepConfig.
- level (int): Sleep level (1 or 2). Default 1.
"""
assert self._engine_client is not None, "engine_client is not initialized"
config = VLLMSleepConfig(**kwargs)
await self._engine_client.sleep(level=config.level)
async def wakeup(self, **kwargs: Any) -> None:
"""Wake up the vLLM engine from sleep mode.
Args:
**kwargs: Options parsed into VLLMWakeupConfig.
- tags (List[str], optional): Components to wake up.
"""
assert self._engine_client is not None, "engine_client is not initialized"
config = VLLMWakeupConfig(**kwargs)
await self._engine_client.wake_up(tags=config.tags)
async def is_sleeping(self) -> bool:
"""Check whether the vLLM engine is currently sleeping.
Returns:
True if the engine is sleeping, False otherwise.
"""
assert self._engine_client is not None, "engine_client is not initialized"
return await self._engine_client.is_sleeping()
async def pause(self, **kwargs: Any) -> None:
"""Pause generation on the vLLM engine.
This halts generation/encoding requests while keeping model weights
in GPU memory. New requests are blocked until resume is called.
Args:
**kwargs: Options parsed into VLLMPauseConfig.
- mode (str): "abort" (default), "wait", or "keep".
- clear_cache (bool): Clear KV cache after draining. Default True.
"""
assert self._engine_client is not None, "engine_client is not initialized"
config = VLLMPauseConfig(**kwargs)
await self._engine_client.pause_generation(
mode=config.mode,
clear_cache=config.clear_cache,
)
async def resume(self, **kwargs: Any) -> None:
"""Resume generation on the vLLM engine after pause.
Args:
**kwargs: Reserved for future options.
"""
assert self._engine_client is not None, "engine_client is not initialized"
await self._engine_client.resume_generation()
async def is_paused(self) -> bool:
"""Check whether the vLLM engine is currently paused.
Returns:
True if the engine is paused, False otherwise.
"""
assert self._engine_client is not None, "engine_client is not initialized"
return await self._engine_client.is_paused()
async def start_profile(self) -> None:
assert self._engine_client is not None, "engine_client is not initialized"
await self._engine_client.start_profile()
async def stop_profile(self) -> None:
assert self._engine_client is not None, "engine_client is not initialized"
await self._engine_client.stop_profile()
async def collective_rpc(
self,
method: str,
timeout: Optional[float] = None,
args: tuple = (),
kwargs: Optional[dict] = None,
) -> list:
"""Execute a collective RPC call on all vLLM workers.
This is used for RLHF workflows where a trainer needs to execute
methods on all TP/PP workers (e.g., for weight synchronization).
Args:
method: Name of the worker method to execute.
timeout: Maximum time in seconds to wait for execution.
args: Positional arguments to pass to the worker method.
kwargs: Keyword arguments to pass to the worker method.
Returns:
A list containing the results from each worker.
"""
assert self._engine_client is not None, "engine_client is not initialized"
return await self._engine_client.collective_rpc(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs or {},
)
@@ -0,0 +1,361 @@
import dataclasses
import os
from typing import Any, Dict, List, Optional
from pydantic import ConfigDict, Field, PrivateAttr, field_validator, model_validator
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.entrypoints.openai.cli_args import FrontendArgs
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.placement import PlacementGroupConfig
from ray.llm._internal.common.utils.cloud_utils import CloudMirrorConfig, is_remote_path
from ray.llm._internal.common.utils.import_utils import try_import
from ray.llm._internal.serve.constants import (
ALLOW_NEW_PLACEMENT_GROUPS_IN_DEPLOYMENT,
ENV_VARS_TO_PROPAGATE,
)
from ray.llm._internal.serve.core.configs.accelerators import (
AcceleratorBackend,
AnyAcceleratorConfig,
CPUAccelerator,
CPUConfig,
GPUAccelerator,
TPUAccelerator,
TPUConfig,
format_ray_accelerator_resource,
)
from ray.llm._internal.serve.core.configs.llm_config import (
AcceleratorType,
LLMConfig,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.util.placement_group import (
PlacementGroup,
get_current_placement_group,
placement_group_table,
)
# The key for the kv_transfer_params in the internal metadata.
KV_TRANSFER_PARAMS_KEY = "kv_transfer_params"
vllm = try_import("vllm")
logger = get_logger(__name__)
# Executor backend constants
EXECUTOR_BACKEND_RAY = "ray"
EXECUTOR_BACKEND_MP = "mp"
class VLLMEngineConfig(BaseModelExtended):
model_config = ConfigDict(
use_enum_values=True,
)
model_id: str = Field(
description="The identifier for the model. This is the id that will be used to query the model.",
)
hf_model_id: Optional[str] = Field(
None, description="The Hugging Face model identifier."
)
mirror_config: Optional[CloudMirrorConfig] = Field(
None,
description="Configuration for cloud storage mirror. This is for where the weights are downloaded from.",
)
accelerator_type: Optional[AcceleratorType] = Field(
None,
description="The type of accelerator to use. This is used to determine the placement group strategy.",
)
accelerator_config: Optional[AnyAcceleratorConfig] = Field(
default=None,
description=(
"Hardware-specific configuration parameters for the chosen accelerator. "
"The expected schema is dynamically typed based on the 'kind' discriminator."
),
)
placement_group_config: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"Ray placement group configuration for scheduling vLLM engine workers. "
"Defines resource bundles and placement strategy for multi-node deployments. "
"Defaults to PACK strategy with automatic bundle generation based on TP/PP sizes."
),
)
@field_validator("placement_group_config")
@classmethod
def validate_placement_group_config(cls, value):
if value is None:
return None
# Validate through PlacementGroupConfig, then dump back to dict
validated = PlacementGroupConfig(**value)
return validated.model_dump(exclude_unset=True)
runtime_env: Optional[Dict[str, Any]] = None
engine_kwargs: Dict[str, Any] = {}
frontend_kwargs: Dict[str, Any] = {}
_accelerator: AcceleratorBackend = PrivateAttr(default=None)
@model_validator(mode="after")
def _build_accelerator(self):
"""Instantiates the accelerator backend based on the resolved config."""
cfg = self.accelerator_config
if self.accelerator_type and isinstance(cfg, CPUConfig):
raise ValueError(
f"accelerator_type='{self.accelerator_type}' cannot be used with "
"CPU-only configurations. Either remove accelerator_type, or provide an accelerator_config."
)
# LLMConfig has already resolved and validated accelerator_config
if isinstance(cfg, TPUConfig):
self._accelerator = TPUAccelerator(cfg)
elif isinstance(cfg, CPUConfig):
self._accelerator = CPUAccelerator()
else:
# Default to GPU if it's GPUConfig or isn't set
self._accelerator = GPUAccelerator()
return self
@property
def accelerator(self) -> AcceleratorBackend:
return self._accelerator
@property
def actual_hf_model_id(self) -> str:
return self.hf_model_id or self.model_id
@property
def trust_remote_code(self) -> bool:
return self.engine_kwargs.get("trust_remote_code", False)
def get_initialization_kwargs(self) -> dict:
"""
Get kwargs that will be actually passed to the LLMInitializer
constructor.
"""
engine_kwargs = self.engine_kwargs.copy()
if "model" in engine_kwargs or "served_model_name" in engine_kwargs:
raise ValueError(
"model or served_model_name is not allowed in engine_kwargs when using Ray Serve LLM. Please use `model_loading_config` in LLMConfig instead."
)
engine_kwargs["model"] = self.actual_hf_model_id
engine_kwargs["served_model_name"] = [self.model_id]
# Handle distributed_executor_backend based on backend type
if isinstance(self.accelerator, CPUAccelerator):
executor_backend = EXECUTOR_BACKEND_MP
else:
executor_backend = EXECUTOR_BACKEND_RAY
if (
"distributed_executor_backend" in engine_kwargs
and engine_kwargs["distributed_executor_backend"] != executor_backend
and executor_backend == EXECUTOR_BACKEND_RAY
):
raise ValueError(
"distributed_executor_backend != 'ray' is not allowed in engine_kwargs when using Ray Serve LLM Configs."
)
engine_kwargs["distributed_executor_backend"] = executor_backend
if "enable_log_requests" not in engine_kwargs:
engine_kwargs["enable_log_requests"] = False
return engine_kwargs
def get_runtime_env_with_local_env_vars(self) -> dict:
runtime_env = self.runtime_env or {}
runtime_env.setdefault("env_vars", {})
env_vars = runtime_env["env_vars"]
# Propagate env vars to the runtime env
for env_var in ENV_VARS_TO_PROPAGATE:
if env_var in os.environ:
env_vars[env_var] = os.getenv(env_var)
if "VLLM_RAY_PER_WORKER_GPUS" not in env_vars:
fractional_gpu = self._detect_fractional_gpu_from_pg(
self.placement_group_config
)
if fractional_gpu is not None:
env_vars["VLLM_RAY_PER_WORKER_GPUS"] = str(fractional_gpu)
return runtime_env
@classmethod
def from_llm_config(cls, llm_config: LLMConfig) -> "VLLMEngineConfig":
"""Converts the LLMConfig to a VLLMEngineConfig."""
# Set up the model downloading configuration.
hf_model_id, mirror_config = None, None
if llm_config.model_loading_config.model_source is None:
hf_model_id = llm_config.model_id
elif isinstance(llm_config.model_loading_config.model_source, str):
model_source = llm_config.model_loading_config.model_source
if is_remote_path(model_source):
# Remote URIs (s3://, gs://, …) are download addresses,
# not HuggingFace IDs. Using the URI verbatim as
# hf_model_id propagates the scheme and slashes into the
# cache directory name (``models--s3:----bucket--…``).
# Use the user-supplied model_id as the identifier and
# treat the URI as a bucket mirror instead.
hf_model_id = llm_config.model_id
mirror_config = CloudMirrorConfig(bucket_uri=model_source)
else:
hf_model_id = model_source
else:
# If it's a CloudMirrorConfig (or subtype)
mirror_config = llm_config.model_loading_config.model_source
all_engine_kwargs = llm_config.engine_kwargs.copy()
engine_kwargs = {}
frontend_kwargs = {}
# Get field names from dataclasses
frontend_field_names = {
field.name for field in dataclasses.fields(FrontendArgs)
}
async_engine_field_names = {
field.name for field in dataclasses.fields(AsyncEngineArgs)
}
for key, value in all_engine_kwargs.items():
if key in frontend_field_names:
frontend_kwargs[key] = value
elif key in async_engine_field_names:
engine_kwargs[key] = value
else:
raise ValueError(f"Unknown engine argument: {key}")
# placement_group_config is already validated and stored as dict in LLMConfig
placement_group_config = llm_config.placement_group_config
return VLLMEngineConfig(
model_id=llm_config.model_id,
hf_model_id=hf_model_id,
mirror_config=mirror_config,
accelerator_type=llm_config.accelerator_type,
accelerator_config=llm_config.accelerator_config,
engine_kwargs=engine_kwargs,
frontend_kwargs=frontend_kwargs,
runtime_env=llm_config.runtime_env,
placement_group_config=placement_group_config,
)
@property
def tensor_parallel_degree(self) -> int:
return self.engine_kwargs.get("tensor_parallel_size", 1)
@property
def pipeline_parallel_degree(self) -> int:
return self.engine_kwargs.get("pipeline_parallel_size", 1)
@property
def num_devices(self) -> int:
return self.tensor_parallel_degree * self.pipeline_parallel_degree
@property
def placement_strategy(self) -> str:
# Use custom strategy if placement_group_config is provided
if self.placement_group_config:
return self.placement_group_config.get("strategy", "PACK")
# Default to PACK (cross-node best-effort placement)
# DP deployments overridden to STRICT_PACK in Serve config
return "PACK"
@property
def placement_bundles(self) -> List[Dict[str, float]]:
if self.placement_group_config:
bundle_per_worker = self.placement_group_config.get("bundle_per_worker")
if bundle_per_worker is not None:
# Expand bundle_per_worker to num_devices bundles
bundles = []
for _ in range(self.num_devices):
bundle = bundle_per_worker.copy()
if self.accelerator_type:
res_key = format_ray_accelerator_resource(self.accelerator_type)
bundle.setdefault(res_key, 0.001)
bundles.append(bundle)
return bundles
# Otherwise use explicit bundles list
bundles = []
explicit_bundles = self.placement_group_config.get("bundles") or []
for bundle_dict in explicit_bundles:
bundle = bundle_dict.copy()
if self.accelerator_type:
# Use setdefault to add accelerator hint WITHOUT overriding explicit user values
res_key = format_ray_accelerator_resource(self.accelerator_type)
bundle.setdefault(res_key, 0.001)
bundles.append(bundle)
return bundles
# Default bundles based on the accelerator backend.
return self.accelerator.default_bundles(
num_devices=self.num_devices, accelerator_type_str=self.accelerator_type
)
def get_or_create_pg(self) -> PlacementGroup:
"""Gets or creates a placement group.
If we are already in a placement group, return the existing placement group.
Else, delegate PG creation to the accelerator backend.
"""
dp_rank = self.engine_kwargs.get("data_parallel_rank", None)
pg = get_current_placement_group()
if pg:
logger.debug(
"Using existing placement group %s, details: %s",
pg.id,
placement_group_table(pg),
)
else:
if not ALLOW_NEW_PLACEMENT_GROUPS_IN_DEPLOYMENT:
raise RuntimeError(
"Creating new placement groups is not allowed. "
"Change RAYLLM_ALLOW_NEW_PLACEMENT_GROUPS_IN_DEPLOYMENT "
"if this is not intended."
)
name = "" if dp_rank is None else f"dp_{dp_rank}"
pg = self.accelerator.create_placement_group(
bundles=self.placement_bundles,
strategy=self.placement_strategy,
name=name,
accelerator_type_str=self.accelerator_type,
)
logger.info(f"Using new placement group {pg}. {placement_group_table(pg)}")
return pg
@staticmethod
def _detect_fractional_gpu_from_pg(
placement_group_config: Optional[Dict[str, Any]]
) -> Optional[float]:
if not placement_group_config:
return None
# Check bundle_per_worker first
bundle_per_worker = placement_group_config.get("bundle_per_worker")
if bundle_per_worker:
gpu_value = bundle_per_worker.get("GPU", 0)
if 0 < gpu_value < 1:
return gpu_value
return None
# Fall back to bundles list
bundles = placement_group_config.get("bundles") or []
for bundle in bundles:
if "GPU" not in bundle:
continue
gpu_value = bundle["GPU"]
if gpu_value <= 0 or gpu_value >= 1:
return None
return gpu_value
return None