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,14 @@
from ray.llm._internal.common.observability.logging_utils import (
disable_vllm_custom_ops_logger_on_cpu_nodes,
)
from ray.llm._internal.serve.observability import setup_observability
# Set up observability
disable_vllm_custom_ops_logger_on_cpu_nodes()
setup_observability()
def _worker_process_setup_hook():
"""Noop setup hook used for ENABLE_WORKER_PROCESS_SETUP_HOOK
(see python/ray/llm/_internal/serve/configs/constants.py)."""
pass
@@ -0,0 +1,196 @@
# Multi-Turn LLM Benchmark
A benchmark tool for OpenAI-compatible LLM inference servers that supports
multi-turn conversations with configurable prefix cache hit rates, input/output
sequence lengths, and cross-session prefix sharing.
## Entry Point
```
python -m ray.llm._internal.serve.benchmark.cli [OPTIONS]
```
## Modes
| Command | Mode | Description |
|---------|------|-------------|
| `... -s` | Smoke | Single request health check |
| `... --concurrency 8 ...` | Direct (concurrency) | Closed-loop concurrency benchmark |
| `... --request-rate 10 ...` | Direct (rate) | Constant-QPS benchmark |
| `... -i` | Interactive server | Long-running server with UNIX socket control |
| `... -i --client` | Interactive client | Connect to server; REPL or `--cmd` one-shot |
## Quick Examples
### Smoke test
```bash
python -m ray.llm._internal.serve.benchmark.cli -s \
-u http://localhost:8000 -m my-model
```
### Concurrency benchmark
```bash
python -m ray.llm._internal.serve.benchmark.cli \
-u http://localhost:8000 -m meta-llama/Llama-3-8B-Instruct \
--concurrency 8 --num-sessions 200 \
--isl 2000 --osl 200 --hit-rate 0.85 --num-turns 5 \
--think-time 1.0 --save-result results.json
```
### Rate benchmark
```bash
python -m ray.llm._internal.serve.benchmark.cli \
-u http://localhost:8000 -m meta-llama/Llama-3-8B-Instruct \
--request-rate 10 --duration 120 \
--isl 2000 --osl 200 --hit-rate 0.85 --num-turns 5 \
--warm-up 10 --save-result results.json
```
### Interactive server
```bash
python -m ray.llm._internal.serve.benchmark.cli -i \
-u http://localhost:8000 -m meta-llama/Llama-3-8B-Instruct \
--isl 2000 --osl 200 --hit-rate 0.85 --num-turns 5
```
### Interactive client (REPL)
```bash
python -m ray.llm._internal.serve.benchmark.cli -i --client
```
### Interactive client (one-shot)
```bash
python -m ray.llm._internal.serve.benchmark.cli -i --client --cmd "rate 10"
python -m ray.llm._internal.serve.benchmark.cli -i --client --cmd "status"
```
## Workload Parameters
All workload parameters use **simple mode**: you specify user-facing values
and the tool derives internal parameters (per-turn user tokens `u` and
system prompt tokens `s`) automatically.
| Parameter | Flag | Description |
|-----------|------|-------------|
| ISL | `--isl` | Average input sequence length (tokens) across all turns |
| OSL | `--osl` | Output tokens per turn |
| Hit rate | `--hit-rate` | Target prefix cache hit rate [0, 1] |
| Shared system prompt ratio | `--shared-system-prompt-ratio` | Fraction of system prompt shared across sessions (default: 0.0) |
| Num turns | `--num-turns` | Number of turns per conversation session |
| Think time | `--think-time` | Simulated user think-time between turns in seconds (default: 0) |
| First chunk threshold | `--first-chunk-threshold` | Number of SSE content chunks before recording first-chunk latency (default: 16) |
The solver derives `user_tokens` (new user tokens per turn) and `sys_tokens`
(total system prompt tokens) from these inputs. The `print_summary()` output
shows the resolved per-turn token breakdown including cached vs. new tokens at
each turn.
## Tokenizer
By default, `--tokenizer` is `None`, which causes the tool to use the
`--model` value as the HuggingFace tokenizer name. This works when `--model`
is a valid HuggingFace model ID (e.g., `meta-llama/Llama-3-8B-Instruct`).
Provide `--tokenizer` explicitly when:
- The `--model` value is an alias or deployment name that is not a valid
HuggingFace repo (e.g., `--model my-deployment --tokenizer meta-llama/Llama-3-8B-Instruct`).
- You want to use a local tokenizer path.
## Warm-Up Strategies
### Concurrency mode
Warm-up is **automatic** using entropy-based detection. The tool monitors the
distribution of active turns across concurrent sessions. Once the Shannon
entropy of the turn distribution reaches 50% of its theoretical maximum, the
pool is considered at steady state and measurement begins. All requests
dispatched before that point are discarded.
### Rate mode
Warm-up is **time-based** via the `--warm-up` flag (in seconds). All requests
whose dispatch time falls within the warm-up window are excluded from reported
metrics. Set this to allow the server's KV cache to fill and stabilize.
### Interactive mode
Warm-up is **manual**. The operator starts traffic with `rate <qps>`, waits
for the system to stabilize, then explicitly starts a measurement window with
`start` or `measure <n>`.
## Interactive Commands
| Command | Description |
|---------|-------------|
| `help` | Show available commands |
| `rate <qps>` | Set target request rate (0 to pause) |
| `start` | Start open-ended measurement window |
| `measure <n>` | Start measurement capturing next `n` completed requests |
| `stop` | Stop measurement and print summary |
| `status` | Show current state: QPS, inflight, completed, measured |
| `workload [k=v ...]` | Show or update workload parameters (e.g., `workload isl=3000 osl=300`) |
| `save [path]` | Save last measurement window to JSON |
| `save-dir <path>` | Set default directory for saved results |
| `quit` | Stop the benchmark server |
## JSON Result Schema
Results saved with `--save-result` (direct mode) contain these top-level keys:
| Key | Description |
|-----|-------------|
| `config` | Run configuration (concurrency/rate, model, etc.) |
| `spec` | Resolved workload spec with per-turn token breakdown |
| `first_chunk_threshold` | Number of chunks before recording first-chunk latency |
| `benchmark` | Run metadata: total requests, duration, warm-up info |
| `stats` | Aggregate latency statistics (avg, P50, P90, P99 for TTFT, FC, TPOT, latency) |
| `per_turn` | Per-turn breakdown of count, avg ISL, and latency percentiles |
| `raw_metrics` | Array of per-request metrics (session_id, turn, all latency fields, token counts) |
Interactive mode saves with `save` produce a similar structure with a `window`
summary instead of `benchmark`/`stats`/`per_turn`.
## Typical Workflow
1. **Smoke test** to verify connectivity:
```bash
python -m ray.llm._internal.serve.benchmark.cli -s -u http://localhost:8000 -m my-model
```
2. **Direct benchmark** for a fixed workload:
```bash
python -m ray.llm._internal.serve.benchmark.cli \
--concurrency 8 --num-sessions 200 \
--isl 2000 --osl 200 --hit-rate 0.85 --num-turns 5 \
-u http://localhost:8000 -m meta-llama/Llama-3-8B-Instruct \
--save-result concurrency_8.json
```
3. **Interactive mode** for exploratory testing:
```bash
# Terminal 1: start server
python -m ray.llm._internal.serve.benchmark.cli -i \
--isl 2000 --osl 200 --hit-rate 0.85 --num-turns 5 \
-u http://localhost:8000 -m meta-llama/Llama-3-8B-Instruct
# Terminal 2: control
python -m ray.llm._internal.serve.benchmark.cli -i --client
benchctl> rate 5
benchctl> measure 500
benchctl> status
benchctl> save results_qps5.json
benchctl> rate 10
benchctl> measure 500
benchctl> save results_qps10.json
benchctl> quit
```
4. **Sweep** over multiple configurations: write an external script that loops
over the CLI with different parameters. The tool does not include built-in
sweep orchestration.
@@ -0,0 +1,7 @@
"""CLI entry point for the multi-turn OpenAI-compatible HTTP benchmark.
Example: python -m ray.llm._internal.serve.benchmark --help
"""
from ray.llm._internal.serve.benchmark.cli import main
main()
@@ -0,0 +1,233 @@
"""CLI entry point for the multi-turn OpenAI-compatible HTTP benchmark."""
import argparse
import sys
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m ray.llm._internal.serve.benchmark",
description="Multi-turn OpenAI-compatible HTTP benchmark",
)
## Mode flags ##
mode = parser.add_argument_group("mode")
mode.add_argument(
"-s",
"--smoke",
action="store_true",
help="Smoke test (single request, exit)",
)
mode.add_argument(
"-i",
"--interactive",
action="store_true",
help="Interactive mode (server by default)",
)
mode.add_argument(
"--client",
action="store_true",
help="Interactive client mode (used with -i)",
)
## Server / API ##
server = parser.add_argument_group("server/API")
server.add_argument(
"-u",
"--base-url",
default="http://127.0.0.1:8000",
help="Base URL of the OpenAI-compatible API (default: %(default)s)",
)
server.add_argument(
"-m",
"--model",
default=None,
help="Model name to send in requests (required except for -i --client)",
)
server.add_argument(
"--tokenizer",
default=None,
help="HuggingFace tokenizer name/path (default: same as --model)",
)
server.add_argument(
"--api-key",
default=None,
help="API key for Authorization header (default: None)",
)
## Workload ##
workload = parser.add_argument_group("workload")
workload.add_argument(
"--isl",
type=int,
default=1000,
help="Average input sequence length (default: %(default)s)",
)
workload.add_argument(
"--hit-rate",
type=float,
default=0.5,
help="Prefix cache hit rate [0, 1] (default: %(default)s)",
)
workload.add_argument(
"--num-turns",
type=int,
default=1,
help="Number of turns per session (default: %(default)s)",
)
workload.add_argument(
"--osl",
type=int,
default=100,
help="Output tokens per turn (default: %(default)s)",
)
workload.add_argument(
"--shared-system-prompt-ratio",
dest="shared_system_prompt_ratio",
type=float,
default=1.0,
help="Fraction of the system prompt shared across all sessions "
"(1.0 = identical, 0.0 = all unique) (default: %(default)s)",
)
workload.add_argument(
"--think-time",
type=float,
default=0.0,
help="Simulated user think-time between turns in seconds (default: %(default)s)",
)
workload.add_argument(
"-fc",
"--first-chunk-threshold",
type=int,
default=16,
help="Number of content chunks before recording first-chunk latency (default: %(default)s)",
)
## Traffic ##
traffic = parser.add_argument_group("traffic")
traffic.add_argument(
"--concurrency",
type=int,
default=None,
help="Number of concurrent sessions",
)
traffic.add_argument(
"--request-rate",
type=float,
default=None,
help="Request rate (requests per second)",
)
traffic.add_argument(
"--duration",
type=float,
default=None,
help="Duration in seconds",
)
traffic.add_argument(
"--num-sessions",
type=int,
default=None,
help="Total number of sessions to run",
)
traffic.add_argument(
"--warm-up",
type=float,
default=0,
help="Warm-up period in seconds (default: %(default)s)",
)
traffic.add_argument(
"--warmup-jitter-max",
type=float,
default=10.0,
help="Max random delay (seconds) between turns during entropy warm-up "
"in concurrency mode. Jitter desynchronizes sessions so the benchmark "
"reaches steady-state faster (default: %(default)s)",
)
traffic.add_argument(
"--ramp-interval",
type=float,
default=-1,
help="Seconds between launching successive sessions at benchmark start. "
"Use this to avoid a thundering-herd of simultaneous first requests. "
"-1 = auto-derive from request rate or concurrency (default: %(default)s)",
)
## Interactive-only ##
interactive = parser.add_argument_group("interactive-only")
interactive.add_argument(
"--status-interval",
type=int,
default=5,
help="Status reporting interval in seconds (default: %(default)s)",
)
interactive.add_argument(
"--cmd",
type=str,
default=None,
help="Command to send in interactive client mode",
)
interactive.add_argument(
"--log-failures",
action="store_true",
help="Log individual request failures",
)
interactive.add_argument(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility",
)
interactive.add_argument(
"--save-result",
type=str,
default=None,
help="Filename to save results",
)
interactive.add_argument(
"--save-dir",
type=str,
default=None,
help="Directory to save results",
)
interactive.add_argument(
"--num-workers",
type=int,
default=1,
help="Number of process-pool workers for conversation generation (default: %(default)s)",
)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.interactive and args.client:
from ray.llm._internal.serve.benchmark.interactive import run_interactive_client
sys.exit(run_interactive_client(args))
# All other modes require --model
if not args.model:
parser.error("--model is required (except for -i --client mode)")
if args.smoke:
from ray.llm._internal.serve.benchmark.runners import run_smoke
sys.exit(run_smoke(args))
elif args.interactive:
from ray.llm._internal.serve.benchmark.interactive import run_interactive_server
sys.exit(run_interactive_server(args))
elif args.concurrency or args.request_rate:
from ray.llm._internal.serve.benchmark.runners import run_direct
sys.exit(run_direct(args))
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,122 @@
"""HTTP client for OpenAI-compatible chat completion endpoints."""
from __future__ import annotations
import json
import time
from typing import Optional
import aiohttp
from ray.llm._internal.serve.benchmark.models import TurnResult
async def send_chat_completion(
session: aiohttp.ClientSession,
base_url: str,
model: str,
messages: list[dict[str, str]],
session_id: str = "",
max_tokens: int = 256,
first_chunk_threshold: int = 16,
timeout_sec: int = 300,
api_key: Optional[str] = None,
) -> TurnResult:
"""Send a streaming chat completion request and collect metrics."""
url = f"{base_url}/v1/chat/completions"
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
"temperature": 0.0,
}
headers: dict[str, str] = {
"Content-Type": "application/json",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if session_id:
headers["X-Session-Id"] = session_id
timeout = aiohttp.ClientTimeout(total=timeout_sec)
start_ns = time.perf_counter_ns()
ttft_ns: Optional[int] = None
fc_ns: Optional[int] = None
content_chunk_count = 0
chunk_times: list[int] = []
generated_text = ""
input_tokens = 0
output_tokens = 0
prev_ts = start_ns
async with session.post(
url, json=payload, headers=headers, timeout=timeout
) as resp:
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"HTTP {resp.status}: {body[:500]}")
async for raw_line in resp.content:
line = raw_line.strip()
if not line:
continue
text = line.decode("utf-8", errors="replace")
if not text.startswith("data: "):
continue
data_str = text[6:]
if data_str == "[DONE]":
continue
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
usage = data.get("usage")
if usage:
input_tokens = usage.get("prompt_tokens", input_tokens)
output_tokens = usage.get("completion_tokens", output_tokens)
choices = data.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content") or delta.get("reasoning")
if content:
now_ns = time.perf_counter_ns()
content_chunk_count += 1
if ttft_ns is None:
ttft_ns = now_ns - start_ns
else:
chunk_times.append(now_ns - prev_ts)
if fc_ns is None and content_chunk_count >= first_chunk_threshold:
fc_ns = now_ns - start_ns
prev_ts = now_ns
generated_text += content
end_ns = time.perf_counter_ns()
latency_ns = end_ns - start_ns
if ttft_ns is None:
ttft_ns = latency_ns
if fc_ns is None:
fc_ns = latency_ns
itl_ms_list = [t / 1e6 for t in chunk_times]
itl_ms = sum(itl_ms_list) / len(itl_ms_list) if itl_ms_list else 0.0
return TurnResult(
ttft_ms=ttft_ns / 1e6,
fc_ms=fc_ns / 1e6,
itl_ms=itl_ms,
e2e_latency_ms=latency_ns / 1e6,
input_tokens=input_tokens,
output_tokens=output_tokens,
generated_text=generated_text,
itl_ms_list=itl_ms_list,
)
@@ -0,0 +1,824 @@
"""Interactive server and client for the multi-turn benchmark.
The interactive server runs a long-lived benchmark loop whose QPS, workload
parameters, and measurement windows are controlled at runtime via a UNIX
domain socket. The interactive client connects to that socket (either as an
interactive REPL or for one-shot commands).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import random
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
import aiohttp
import numpy as np
from ray.llm._internal.serve.benchmark.metrics import (
serialize_raw_metrics,
summarize_metrics,
)
from ray.llm._internal.serve.benchmark.models import TurnMetric, WorkloadSpec
from ray.llm._internal.serve.benchmark.text_gen import (
Conversation,
TextGenerator,
conversation_factory,
)
from ray.llm._internal.serve.benchmark.turn import execute_single_turn
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
except ImportError:
PromptSession = None # type: ignore[assignment,misc]
FileHistory = None # type: ignore[assignment,misc]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Control socket path
# ---------------------------------------------------------------------------
_DEFAULT_CONTROL_SOCKET = "/tmp/interactive_rate_bench.sock"
def _control_socket_path() -> str:
return os.environ.get("RAY_BENCH_CONTROL_SOCKET", _DEFAULT_CONTROL_SOCKET)
# ---------------------------------------------------------------------------
# Process-pool worker helpers (module-level so they are picklable)
# ---------------------------------------------------------------------------
_worker_tokenizer = None
_worker_text_gen: Optional[TextGenerator] = None
def _pool_initializer(tokenizer_name: str, base_seed: int) -> None:
"""Called once per worker process to load the tokenizer and seed RNG."""
global _worker_tokenizer, _worker_text_gen
from transformers import AutoTokenizer
_worker_tokenizer = AutoTokenizer.from_pretrained(
tokenizer_name, trust_remote_code=True
)
_worker_text_gen = TextGenerator(_worker_tokenizer)
proc_seed = (base_seed + os.getpid()) % (2**32)
random.seed(proc_seed)
np.random.seed(proc_seed)
def _create_conv_in_worker(
session_idx: int,
spec: WorkloadSpec,
shared_system_text: str,
) -> Conversation:
"""Create a Conversation inside a worker process."""
return conversation_factory(session_idx, spec, shared_system_text, _worker_text_gen)
# ============================================================================
# Interactive-mode runtime state & helpers
# ============================================================================
@dataclass
class RuntimeState:
current_qps: float = 0.0
total_completed: int = 0
total_failed: int = 0
inflight: int = 0
measurement_active: bool = False
measurement_start_ns: Optional[int] = None
measurement_metrics: list[TurnMetric] = field(default_factory=list)
measurement_target_requests: Optional[int] = None
last_window_metrics: list[TurnMetric] = field(default_factory=list)
last_window_elapsed_s: float = 0.0
last_notice: Optional[str] = None
save_dir: Optional[str] = None
def _save_window_result(
path: str,
args: argparse.Namespace,
spec: WorkloadSpec,
metrics: list[TurnMetric],
elapsed_s: float,
runtime_qps: float = 0.0,
) -> None:
payload = {
"mode": "interactive_rate",
"saved_at_epoch_s": time.time(),
"config": {
"base_url": args.base_url,
"model": args.model,
"tokenizer": getattr(args, "tokenizer", None) or args.model,
"first_chunk_threshold": args.first_chunk_threshold,
"num_turns": args.num_turns,
"osl": args.osl,
"shared_system_prompt_ratio": args.shared_system_prompt_ratio,
"isl": args.isl,
"hit_rate": args.hit_rate,
"runtime_qps": runtime_qps,
},
"spec": spec.summary(),
"window": summarize_metrics(metrics, elapsed_s),
"raw_metrics": serialize_raw_metrics(metrics),
}
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("w") as f:
json.dump(payload, f, indent=2)
print(f"Saved measurement window to {path}")
def _build_spec(
args: argparse.Namespace, overrides: Optional[dict] = None
) -> WorkloadSpec:
"""Build and resolve a WorkloadSpec from args, optionally merging overrides."""
kw = dict(
num_sessions=1,
duration_s=1.0,
num_turns=args.num_turns,
osl=args.osl,
think_time=0.0,
concurrency=None,
request_rate=1.0,
ramp_interval=0.0,
shared_system_prompt_ratio=args.shared_system_prompt_ratio,
isl=args.isl,
hit_rate=args.hit_rate,
)
if overrides:
kw.update(overrides)
spec = WorkloadSpec(**kw)
spec.resolve()
return spec
# ============================================================================
# Command handler (extracted for testability)
# ============================================================================
class CommandHandler:
"""Handles interactive benchmark commands.
Extracted from the ``run_interactive`` closure so that command parsing,
state mutation, and response formatting can be unit-tested without
starting a real server or HTTP session.
"""
def __init__(
self,
runtime: RuntimeState,
workload: dict,
args: argparse.Namespace,
text_gen: Optional[TextGenerator] = None,
rate_changed: Optional[asyncio.Event] = None,
workload_changed: Optional[asyncio.Event] = None,
stop_event: Optional[asyncio.Event] = None,
):
self.runtime = runtime
self.workload = workload
self.args = args
self.text_gen = text_gen
self.rate_changed = rate_changed or asyncio.Event()
self.workload_changed = workload_changed or asyncio.Event()
self.stop_event = stop_event or asyncio.Event()
def resolve_save_path(self, raw: Optional[str]) -> str:
if raw:
expanded = str(Path(raw).expanduser())
if "/" in expanded or expanded.startswith("."):
return expanded
return str(Path(self.runtime.save_dir) / expanded)
if self.args.save_result:
return str(Path(self.args.save_result).expanduser())
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
qps_label = f"{self.runtime.current_qps:.2f}".replace(".", "p")
return str(
Path(self.runtime.save_dir)
/ f"interactive_measure_qps{qps_label}_{ts}.json"
)
async def handle(self, cmd: str) -> str: # noqa: C901
"""Process a single command string and return the response."""
cmd = cmd.strip()
if not cmd:
return "empty command"
parts = cmd.split()
op = parts[0].lower()
if op == "help":
return (
"Commands: help, rate <qps>, start, measure <n>, stop, "
"status, save [path|name], save-dir <path>, quit\n"
"Workload: workload [isl=N] [osl=N] [hit-rate=F] "
"[sharing=F] [num-turns=N]\n"
" e.g. workload isl=2000 osl=200 hit-rate=0.5\n"
" All params optional; unspecified ones keep their current values.\n"
" workload (no args) prints current workload spec."
)
if op == "rate":
if len(parts) != 2:
return "Usage: rate <qps>"
try:
new_qps = float(parts[1])
if new_qps < 0:
raise ValueError()
except ValueError:
return "QPS must be a non-negative number."
self.runtime.current_qps = new_qps
self.rate_changed.set()
return f"Set target qps={new_qps:.3f}"
if op == "start":
self.runtime.measurement_active = True
self.runtime.measurement_start_ns = time.perf_counter_ns()
self.runtime.measurement_metrics = []
self.runtime.measurement_target_requests = None
self.runtime.last_notice = None
return "Measurement started."
if op == "measure":
if len(parts) != 2:
return "Usage: measure <num_requests>"
try:
tgt = int(parts[1])
if tgt <= 0:
raise ValueError()
except ValueError:
return "measure requires a positive integer."
self.runtime.measurement_active = True
self.runtime.measurement_start_ns = time.perf_counter_ns()
self.runtime.measurement_metrics = []
self.runtime.measurement_target_requests = tgt
self.runtime.last_notice = None
return f"Measurement started: capturing next {tgt} completed requests."
if op == "stop":
if not self.runtime.measurement_active:
return "Measurement is not active."
self.runtime.measurement_active = False
end_ns = time.perf_counter_ns()
start_ns = self.runtime.measurement_start_ns or end_ns
self.runtime.last_window_elapsed_s = (end_ns - start_ns) / 1e9
self.runtime.last_window_metrics = list(self.runtime.measurement_metrics)
self.runtime.measurement_target_requests = None
summary = summarize_metrics(
list(self.runtime.last_window_metrics),
self.runtime.last_window_elapsed_s,
)
return f"Measurement stopped.\n{json.dumps(summary, indent=2)}"
if op == "status":
cur = self.workload["spec"]
status = (
f"qps={self.runtime.current_qps:.2f} "
f"inflight={self.runtime.inflight} "
f"completed={self.runtime.total_completed} "
f"failed={self.runtime.total_failed} "
f"measured={len(self.runtime.measurement_metrics)} "
f"active={self.runtime.measurement_active} "
f"target={self.runtime.measurement_target_requests} "
f"save_dir={self.runtime.save_dir}\n"
f"workload: isl={cur.isl} osl={cur.osl} hit-rate={cur.hit_rate} "
f"sharing={cur.shared_system_prompt_ratio} num-turns={cur.num_turns}"
)
if self.runtime.last_notice:
status += f"\n{self.runtime.last_notice}"
self.runtime.last_notice = None
return status
if op == "save-dir":
if len(parts) != 2:
return "Usage: save-dir <path>"
new_dir = str(Path(parts[1]).expanduser())
self.runtime.save_dir = new_dir
return f"Set save_dir={self.runtime.save_dir}"
if op == "save":
if len(parts) > 2:
return "Usage: save [path.json|name.json]"
if (
self.runtime.measurement_active
and self.runtime.measurement_start_ns is not None
):
el = (time.perf_counter_ns() - self.runtime.measurement_start_ns) / 1e9
mlist = list(self.runtime.measurement_metrics)
else:
el = self.runtime.last_window_elapsed_s
mlist = list(self.runtime.last_window_metrics)
if not mlist:
return "No measured window data to save."
save_path = self.resolve_save_path(parts[1] if len(parts) == 2 else None)
_save_window_result(
save_path,
self.args,
self.workload["spec"],
mlist,
el,
runtime_qps=self.runtime.current_qps,
)
return f"Saved measurement window to {save_path}"
if op == "workload":
cur = self.workload["spec"]
if len(parts) == 1:
return (
f"isl={cur.isl} osl={cur.osl} hit-rate={cur.hit_rate} "
f"sharing={cur.shared_system_prompt_ratio} num-turns={cur.num_turns}"
)
_param_aliases = {
"isl": "isl",
"osl": "osl",
"hit-rate": "hit_rate",
"hitrate": "hit_rate",
"hit_rate": "hit_rate",
"sharing": "shared_system_prompt_ratio",
"shared-system-prompt-ratio": "shared_system_prompt_ratio",
"shared_system_prompt_ratio": "shared_system_prompt_ratio",
"num-turns": "num_turns",
"num_turns": "num_turns",
}
overrides: dict = {}
errors: list[str] = []
for token in parts[1:]:
if "=" not in token:
errors.append(f"bad token {token!r} (expected key=value)")
continue
k, _, v = token.partition("=")
mapped = _param_aliases.get(k.lower())
if mapped is None:
errors.append(f"unknown param {k!r}")
continue
try:
overrides[mapped] = (
int(v) if mapped in ("isl", "osl", "num_turns") else float(v)
)
except ValueError:
errors.append(f"invalid value for {k}: {v!r}")
if errors:
return "Error: " + "; ".join(errors)
merged = dict(
isl=cur.isl,
osl=cur.osl,
hit_rate=cur.hit_rate,
shared_system_prompt_ratio=cur.shared_system_prompt_ratio,
num_turns=cur.num_turns,
)
merged.update(overrides)
try:
new_spec = _build_spec(self.args, merged)
except Exception as e:
return f"Invalid workload spec: {e}"
if self.text_gen is not None:
new_sst = self.text_gen.generate(new_spec.shared_s)
else:
new_sst = ""
self.workload["spec"] = new_spec
self.workload["shared_system_text"] = new_sst
self.workload_changed.set()
new_spec.print_summary()
return (
f"Workload updated: isl={new_spec.isl} osl={new_spec.osl} "
f"hit-rate={new_spec.hit_rate} "
f"sharing={new_spec.shared_system_prompt_ratio} "
f"num-turns={new_spec.num_turns}"
)
if op in ("quit", "exit"):
self.stop_event.set()
return "Stopping benchmark..."
return f"Unknown command: {op}"
# ============================================================================
# Interactive server
# ============================================================================
async def run_interactive(args: argparse.Namespace) -> None:
spec = _build_spec(args)
spec.print_summary()
print("Interactive mode: starts idle. Use 'rate <qps>' to begin sending traffic.")
from concurrent.futures import ProcessPoolExecutor
from transformers import AutoTokenizer
tokenizer_name: str = args.tokenizer if args.tokenizer else args.model
if args.seed is None:
args.seed = random.randint(0, 2**31 - 1)
random.seed(args.seed)
np.random.seed(args.seed % (2**32))
print(f"Seed: {args.seed}")
print(f"Loading tokenizer: {tokenizer_name}")
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True)
text_gen = TextGenerator(tokenizer)
shared_system_text = text_gen.generate(spec.shared_s)
bench_start_ns = time.perf_counter_ns()
workload: dict = {"spec": spec, "shared_system_text": shared_system_text}
workload_changed = asyncio.Event()
default_save_dir = args.save_dir
if default_save_dir is None and args.save_result:
default_save_dir = str(Path(args.save_result).parent)
if default_save_dir is None:
default_save_dir = os.getcwd()
runtime = RuntimeState(
current_qps=0.0,
save_dir=str(Path(default_save_dir).expanduser()),
)
stop_event = asyncio.Event()
rate_changed = asyncio.Event()
ready_queue: asyncio.Queue[tuple[Conversation, int]] = asyncio.Queue()
next_session_idx = 0
running_tasks: set[asyncio.Task] = set()
num_workers = args.num_workers
print(f"Starting process pool with {num_workers} workers")
cpu_pool = ProcessPoolExecutor(
max_workers=num_workers,
initializer=_pool_initializer,
initargs=(tokenizer_name, args.seed),
)
loop = asyncio.get_running_loop()
def _next_session_idx() -> int:
nonlocal next_session_idx
idx = next_session_idx
next_session_idx += 1
return idx
async def next_conv_async() -> Conversation:
idx = _next_session_idx()
s = workload["spec"]
sst = workload["shared_system_text"]
return await loop.run_in_executor(
cpu_pool,
_create_conv_in_worker,
idx,
s,
sst,
)
async def prefill_queue() -> None:
while not stop_event.is_set():
if workload_changed.is_set():
workload_changed.clear()
drained = 0
while not ready_queue.empty():
try:
ready_queue.get_nowait()
drained += 1
except asyncio.QueueEmpty:
break
if drained:
print(
f"[workload] drained {drained} stale conversations from queue.",
flush=True,
)
qps = runtime.current_qps
if qps <= 0:
await asyncio.sleep(0.2)
continue
s = workload["spec"]
sst = workload["shared_system_text"]
target = max(8, int(qps * 2))
current = ready_queue.qsize()
if current < target:
batch_size = min(target - current, num_workers * 2)
idxs = [_next_session_idx() for _ in range(batch_size)]
futs = [
loop.run_in_executor(
cpu_pool,
_create_conv_in_worker,
idx,
s,
sst,
)
for idx in idxs
]
for fut in asyncio.as_completed(futs):
try:
conv = await fut
await ready_queue.put((conv, 0))
except Exception as e:
logger.warning("Failed to create conversation in worker: %s", e)
await asyncio.sleep(0.02)
async def execute_turn(
conv: Conversation, turn_idx: int, http_session: aiohttp.ClientSession
) -> None:
cur_spec = workload["spec"]
runtime.inflight += 1
try:
outcome = await execute_single_turn(
http_session=http_session,
conv=conv,
turn_idx=turn_idx,
base_url=args.base_url,
model=args.model,
max_tokens=cur_spec.osl,
bench_start_ns=bench_start_ns,
first_chunk_threshold=args.first_chunk_threshold,
api_key=getattr(args, "api_key", None),
)
metric = outcome.metric
auto_complete_summary: Optional[str] = None
runtime.total_completed += 1
if runtime.measurement_active:
target = runtime.measurement_target_requests
if target is None:
runtime.measurement_metrics.append(metric)
elif len(runtime.measurement_metrics) < target:
runtime.measurement_metrics.append(metric)
if target is not None and len(runtime.measurement_metrics) >= target:
runtime.measurement_active = False
end_ns = time.perf_counter_ns()
start_ns = runtime.measurement_start_ns or end_ns
runtime.last_window_elapsed_s = (end_ns - start_ns) / 1e9
runtime.last_window_metrics = list(
runtime.measurement_metrics[:target]
)
runtime.measurement_target_requests = None
summary = summarize_metrics(
runtime.last_window_metrics,
runtime.last_window_elapsed_s,
)
auto_complete_summary = json.dumps(summary, indent=2)
runtime.last_notice = (
f"measurement auto-complete ({target} req):\n"
f"{auto_complete_summary}"
)
if auto_complete_summary is not None:
print("Measurement auto-complete:")
print(auto_complete_summary)
next_turn = turn_idx + 1
if not stop_event.is_set():
if next_turn < cur_spec.num_turns:
await ready_queue.put((conv, next_turn))
else:
conv = await next_conv_async()
await ready_queue.put((conv, 0))
except Exception as e:
if args.log_failures:
print(
f"[request-failed] session={conv.session_id} turn={turn_idx}: {e}"
)
runtime.total_failed += 1
if not stop_event.is_set():
conv = await next_conv_async()
await ready_queue.put((conv, 0))
finally:
runtime.inflight -= 1
async def pacer(http_session: aiohttp.ClientSession) -> None:
next_dispatch = time.perf_counter()
while not stop_event.is_set():
qps = runtime.current_qps
if qps <= 0:
await asyncio.sleep(0.1)
next_dispatch = time.perf_counter() + 0.05
continue
if rate_changed.is_set():
rate_changed.clear()
next_dispatch = time.perf_counter() + (1.0 / qps)
now = time.perf_counter()
if next_dispatch < now - 1.0:
next_dispatch = now
wait = next_dispatch - now
if wait > 0:
await asyncio.sleep(wait)
if stop_event.is_set():
break
try:
conv, turn_idx = ready_queue.get_nowait()
except asyncio.QueueEmpty:
next_dispatch += 1.0 / qps
continue
t = asyncio.create_task(execute_turn(conv, turn_idx, http_session))
running_tasks.add(t)
t.add_done_callback(running_tasks.discard)
next_dispatch += 1.0 / qps
async def reporter() -> None:
if args.status_interval <= 0:
return
while not stop_event.is_set():
await asyncio.sleep(args.status_interval)
print(
"status: "
f"qps={runtime.current_qps:.2f} "
f"inflight={runtime.inflight} "
f"completed={runtime.total_completed} "
f"failed={runtime.total_failed} "
f"measured={len(runtime.measurement_metrics)} "
f"active={runtime.measurement_active}",
flush=True,
)
cmd_handler = CommandHandler(
runtime=runtime,
workload=workload,
args=args,
text_gen=text_gen,
rate_changed=rate_changed,
workload_changed=workload_changed,
stop_event=stop_event,
)
handle_command = cmd_handler.handle
async def stdin_command_loop() -> None:
print(
"Interactive commands: help | rate <qps> | start | measure <n> | "
"stop | status | workload [k=v ...] | save [path|name] | "
"save-dir <path> | quit"
)
while not stop_event.is_set():
raw = await asyncio.to_thread(input, "bench> ")
resp = await handle_command(raw)
if resp:
print(resp)
async def socket_command_handler(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
data = await reader.read(4096)
cmd = data.decode("utf-8", errors="replace").strip()
resp = await handle_command(cmd)
writer.write((resp + "\n").encode("utf-8"))
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
# Seed the ready queue
seed_count = 2
seed_idxs = [_next_session_idx() for _ in range(seed_count)]
seed_futs = [
loop.run_in_executor(
cpu_pool,
_create_conv_in_worker,
idx,
spec,
shared_system_text,
)
for idx in seed_idxs
]
for conv in await asyncio.gather(*seed_futs):
await ready_queue.put((conv, 0))
control_socket = _control_socket_path()
if os.path.exists(control_socket):
os.unlink(control_socket)
socket_server = await asyncio.start_unix_server(
socket_command_handler,
path=control_socket,
)
print(f"Control socket listening at: {control_socket}")
stdin_control = getattr(args, "stdin_control", False)
if not stdin_control:
print("Use a second terminal with --client to send commands.")
connector = aiohttp.TCPConnector(limit=0)
async with aiohttp.ClientSession(connector=connector) as http_session:
background_tasks = [
asyncio.create_task(pacer(http_session)),
asyncio.create_task(reporter()),
asyncio.create_task(socket_server.serve_forever()),
asyncio.create_task(prefill_queue()),
]
stdin_task = (
asyncio.create_task(stdin_command_loop()) if stdin_control else None
)
if stdin_task is not None:
await stdin_task
else:
await stop_event.wait()
stop_event.set()
socket_server.close()
await socket_server.wait_closed()
await asyncio.gather(*background_tasks, return_exceptions=True)
if running_tasks:
print(f"Waiting for {len(running_tasks)} in-flight request task(s)...")
await asyncio.gather(*list(running_tasks), return_exceptions=True)
cpu_pool.shutdown(wait=False)
if os.path.exists(control_socket):
os.unlink(control_socket)
# ============================================================================
# Interactive client
# ============================================================================
async def _send_command_once(control_socket: str, cmd: str) -> str:
reader, writer = await asyncio.open_unix_connection(control_socket)
writer.write(cmd.encode("utf-8"))
await writer.drain()
if writer.can_write_eof():
writer.write_eof()
data = await reader.read()
writer.close()
await writer.wait_closed()
return data.decode("utf-8", errors="replace").strip()
async def run_client(args: argparse.Namespace) -> None:
control_socket = _control_socket_path()
print(f"Connected to control socket: {control_socket}")
print(
"Type commands: help, rate <qps>, start, measure <n>, stop, status, "
"save [path|name], save-dir <path>, quit"
)
session = None
if PromptSession is not None and FileHistory is not None:
history_path = str(Path("~/.interactive_rate_bench_history").expanduser())
session = PromptSession(history=FileHistory(history_path))
else:
print(
"prompt_toolkit not installed; using basic input(). "
"Install with: pip install prompt_toolkit"
)
while True:
if session is not None:
raw = await asyncio.to_thread(session.prompt, "benchctl> ")
else:
raw = await asyncio.to_thread(input, "benchctl> ")
cmd = raw.strip()
if not cmd:
continue
try:
resp = await _send_command_once(control_socket, cmd)
except (FileNotFoundError, ConnectionRefusedError) as e:
print(f"Failed to connect to server socket: {e}")
return
print(resp)
if cmd.lower() in ("quit", "exit"):
return
async def run_client_oneshot(args: argparse.Namespace) -> None:
if not args.cmd:
raise ValueError("Client mode with --cmd requires a command string.")
control_socket = _control_socket_path()
try:
resp = await _send_command_once(control_socket, args.cmd)
except (FileNotFoundError, ConnectionRefusedError) as e:
raise RuntimeError(f"Failed to connect to server socket: {e}") from e
print(resp)
# ============================================================================
# Entry points for cli.py
# ============================================================================
def run_interactive_server(args: argparse.Namespace) -> int:
"""Entry point for interactive server mode."""
try:
asyncio.run(run_interactive(args))
return 0
except Exception as e:
logger.error("Interactive server failed: %s", e)
return 1
def run_interactive_client(args: argparse.Namespace) -> int:
"""Entry point for interactive client mode."""
try:
if args.cmd:
asyncio.run(run_client_oneshot(args))
else:
asyncio.run(run_client(args))
return 0
except Exception as e:
logger.error("Interactive client failed: %s", e)
return 1
@@ -0,0 +1,81 @@
"""Metrics computation and serialization for the benchmark."""
from __future__ import annotations
from statistics import mean
import numpy as np
from ray.llm._internal.serve.benchmark.models import TurnMetric
def percentile(values: list[float], p: float) -> float:
"""Compute the p-th percentile (0-100)."""
if not values:
return 0.0
return float(np.percentile(values, p))
def summarize_metrics(metrics: list[TurnMetric], elapsed_s: float) -> dict:
"""Compute aggregate statistics from a list of TurnMetrics.
ITL (inter-token latency) statistics are computed from raw per-token values
flattened across all requests, capturing the full distribution including variance.
"""
if not metrics:
return {"requests": 0, "elapsed_s": round(elapsed_s, 2)}
ttft = [m.ttft_ms for m in metrics]
fc = [m.fc_ms for m in metrics]
# Flatten per-token ITL values across all requests for accurate distribution stats
itl_all = [v for m in metrics for v in m.itl_ms_list]
latency = [m.e2e_latency_ms for m in metrics]
out_tok = [m.output_tokens for m in metrics]
in_tok = [m.input_tokens for m in metrics]
total_output_tokens = sum(out_tok)
return {
"requests": len(metrics),
"elapsed_s": round(elapsed_s, 2),
"request_rate": round(len(metrics) / elapsed_s, 2) if elapsed_s > 0 else 0.0,
"throughput_tok_s": round(total_output_tokens / elapsed_s, 1)
if elapsed_s > 0
else 0.0,
"avg_input_tokens": round(mean(in_tok), 1),
"avg_output_tokens": round(mean(out_tok), 1),
"avg_ttft_ms": round(mean(ttft), 2),
"p50_ttft_ms": round(percentile(ttft, 50), 2),
"p90_ttft_ms": round(percentile(ttft, 90), 2),
"p99_ttft_ms": round(percentile(ttft, 99), 2),
"avg_fc_ms": round(mean(fc), 2),
"p50_fc_ms": round(percentile(fc, 50), 2),
"p90_fc_ms": round(percentile(fc, 90), 2),
"p99_fc_ms": round(percentile(fc, 99), 2),
"avg_itl_ms": round(float(np.mean(itl_all)), 2) if itl_all else 0.0,
"std_itl_ms": round(float(np.std(itl_all)), 2) if itl_all else 0.0,
"p50_itl_ms": round(percentile(itl_all, 50), 2) if itl_all else 0.0,
"p90_itl_ms": round(percentile(itl_all, 90), 2) if itl_all else 0.0,
"p99_itl_ms": round(percentile(itl_all, 99), 2) if itl_all else 0.0,
"avg_e2e_latency_ms": round(mean(latency), 2),
"p50_e2e_latency_ms": round(percentile(latency, 50), 2),
"p90_e2e_latency_ms": round(percentile(latency, 90), 2),
"p99_e2e_latency_ms": round(percentile(latency, 99), 2),
}
def serialize_raw_metrics(metrics: list[TurnMetric]) -> list[dict]:
"""Serialize TurnMetrics to dicts suitable for JSON output."""
return [
{
"session_id": m.session_id,
"turn": m.turn,
"ttft_ms": round(m.ttft_ms, 2),
"fc_ms": round(m.fc_ms, 2),
"itl_ms": round(m.itl_ms, 2),
"e2e_latency_ms": round(m.e2e_latency_ms, 2),
"input_tokens": m.input_tokens,
"output_tokens": m.output_tokens,
"start_time_ms": round(m.start_time_ms, 2),
}
for m in metrics
]
@@ -0,0 +1,397 @@
"""Data models for the multi-turn benchmark."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import List, Optional
logger = logging.getLogger(__name__)
@dataclass
class TurnResult:
"""Result of a single turn's HTTP request."""
ttft_ms: float # time to first token
fc_ms: float # first-chunk latency (time to N-th content chunk)
itl_ms: float # mean inter-token latency across output tokens
e2e_latency_ms: float # total request latency
input_tokens: int # reported by server (usage.prompt_tokens)
output_tokens: int # reported by server (usage.completion_tokens)
generated_text: str # generated text
itl_ms_list: List[float] = field(default_factory=list) # per-token ITL values
@dataclass
class TurnMetric:
"""Metrics for a single turn."""
session_id: str
turn: int # 0-indexed
ttft_ms: float
fc_ms: float # first-chunk latency
itl_ms: float # mean inter-token latency
e2e_latency_ms: float
input_tokens: int
output_tokens: int
start_time_ms: float # relative to benchmark start
itl_ms_list: List[float] = field(default_factory=list) # per-token ITL values
@dataclass
class WorkloadSpec:
"""Workload specification for multi-turn session benchmarks.
Supports simple mode: specify isl + hit_rate, derive user_tokens and sys_tokens.
All parameters are scalar (fixed) values -- no distributions.
"""
# Core parameters
num_sessions: Optional[int] = None # total unique sessions (None = duration-based)
num_turns: int = 1 # turns per session
osl: int = 1 # output sequence length per turn
think_time: float = 0.0 # seconds between turns within a session
# Traffic (use either concurrency or request_rate, not both)
concurrency: Optional[int] = None # max concurrent in-flight requests
request_rate: Optional[float] = None # requests per second (constant rate mode)
ramp_interval: float = -1.0 # seconds between session launches (-1 = auto)
# Duration-based mode (used with request_rate)
duration_s: float = 0.0 # seconds to run benchmark (0 = use num_sessions)
# Fraction of system prompt shared across all sessions
# 1.0 = identical system prompt, 0.0 = all unique
shared_system_prompt_ratio: float = 1.0
# Simple mode inputs (derive user_tokens, sys_tokens)
isl: Optional[int] = None
hit_rate: Optional[float] = None
# Resolved values (computed by resolve())
_user_tokens: int = field(default=0, init=False, repr=False)
_sys_tokens: int = field(default=0, init=False, repr=False)
def resolve(self) -> "WorkloadSpec":
"""Resolve the spec: derive user_tokens and sys_tokens from inputs. Call after init."""
if self.isl is None or self.hit_rate is None:
raise ValueError("Simple mode requires both --isl and --hit-rate.")
self._validate()
self._derive_from_simple()
return self
def _derive_from_simple(self) -> None:
"""Derive user_tokens and sys_tokens from (ISL, hit_rate, num_turns, OSL, shared_system_prompt_ratio).
Two equations, two unknowns (u = user_tokens, s = sys_tokens):
(1) ISL = s + (n+1)/2 · u + (n-1)/2 · a [average input length]
(2) (1-h)·ISL = (1-f)·s/n + u [average new-token fraction]
where n = num_turns, a = osl, f = shared_system_prompt_ratio, h = hit_rate.
Substituting s from (1) into (2) and solving for u:
u = [ (1-h)·ISL - (1-f)/n · (ISL - (n-1)·a/2) ]
/ [ 1 - (1-f)·(n+1)/(2n) ]
Then s = ISL - (n+1)/2 · u - (n-1)/2 · a.
Special case: when n=1 and f=0, equations (1) and (2) collapse to
s + u = ISL with h = s/(s+u), giving s = h·ISL and u = (1-h)·ISL.
"""
isl = self.isl
h = self.hit_rate
n = self.num_turns
a = self.osl
f = self.shared_system_prompt_ratio
denom = 1 - (1 - f) * (n + 1) / (2 * n)
if abs(denom) < 1e-9:
# n=1, f=0, h=0 (validated earlier): s=0, u=ISL.
sys_tokens = 0.0
user_tokens = float(isl)
else:
numer = (1 - h) * isl - (1 - f) / n * (isl - (n - 1) * a / 2)
user_tokens = numer / denom
sys_tokens = isl - (n + 1) / 2 * user_tokens - (n - 1) / 2 * a
if user_tokens < 0.5 or sys_tokens < -0.5:
suggestions = self._feasibility_suggestions()
which = "user_tokens" if user_tokens < 0.5 else "sys_tokens"
val = user_tokens if user_tokens < 0.5 else sys_tokens
raise ValueError(
f"Derived {which} = {val:.1f} is infeasible with "
f"(ISL={isl}, hit_rate={h}, num_turns={n}, "
f"OSL={a}, shared_system_prompt_ratio={f}).\n"
f"To fix, try one of:\n{suggestions}"
)
self._user_tokens = max(1, int(round(user_tokens)))
self._sys_tokens = max(0, int(round(sys_tokens)))
def _feasibility_suggestions(self) -> str:
"""Compute feasible boundary values for each parameter and return suggestions.
For each workload parameter, search for a boundary value that makes
the solver yield user_tokens >= 0.5 and sys_tokens >= -0.5 (the
minimum values that round to physically meaningful token counts:
at least 1 user token and non-negative system tokens).
"""
isl = self.isl
hit_rate = self.hit_rate
num_turns = self.num_turns
osl = self.osl
sharing = self.shared_system_prompt_ratio
lines = []
def _try_solve(isl_, hit_rate_, num_turns_, osl_, sharing_):
"""Solve for (user_tokens, sys_tokens) or return None if degenerate."""
denom = 1 - (1 - sharing_) * (num_turns_ + 1) / (2 * num_turns_)
if abs(denom) < 1e-9:
if hit_rate_ > 1e-9:
return None
return (float(isl_), 0.0)
numer = (1 - hit_rate_) * isl_ - (1 - sharing_) / num_turns_ * (
isl_ - (num_turns_ - 1) * osl_ / 2
)
user_tokens = numer / denom
sys_tokens = (
isl_ - (num_turns_ + 1) / 2 * user_tokens - (num_turns_ - 1) / 2 * osl_
)
return (user_tokens, sys_tokens)
def _feasible(isl_, hit_rate_, num_turns_, osl_, sharing_):
result = _try_solve(isl_, hit_rate_, num_turns_, osl_, sharing_)
# user_tokens >= 0.5 rounds to at least 1 token per turn;
# sys_tokens >= -0.5 rounds to at least 0 system prompt tokens.
return result is not None and result[0] >= 0.5 and result[1] >= -0.5
# Min ISL (binary search)
lo, hi = isl, isl * 20
if _feasible(hi, hit_rate, num_turns, osl, sharing):
while hi - lo > 1:
mid = (lo + hi) // 2
if _feasible(mid, hit_rate, num_turns, osl, sharing):
hi = mid
else:
lo = mid
lines.append(f" - ISL >= {hi} (with current params)")
# Max OSL
lo, hi = 1, osl
if _feasible(isl, hit_rate, num_turns, lo, sharing):
while hi - lo > 1:
mid = (lo + hi) // 2
if _feasible(isl, hit_rate, num_turns, mid, sharing):
lo = mid
else:
hi = mid
lines.append(f" - OSL <= {lo} (with current ISL={isl})")
# Min hit_rate / max hit_rate (search in 0.01 steps)
for h_try in range(0, 100):
h_val = h_try / 100.0
if _feasible(isl, h_val, num_turns, osl, sharing):
if h_val != hit_rate:
if h_val > hit_rate:
lines.append(
f" - hit_rate >= {h_val:.2f} (with current ISL/OSL)"
)
else:
lines.append(
f" - hit_rate <= {h_val:.2f} (with current ISL/OSL)"
)
break
# Max num_turns
for n_try in range(num_turns, 0, -1):
if _feasible(isl, hit_rate, n_try, osl, sharing):
if n_try != num_turns:
lines.append(f" - num_turns <= {n_try} (with current ISL/OSL)")
break
# Min shared_system_prompt_ratio
if sharing < 1.0:
for f_try in range(int(sharing * 100), 101):
f_val = f_try / 100.0
if _feasible(isl, hit_rate, num_turns, osl, f_val):
if f_val != sharing:
lines.append(f" - shared_system_prompt_ratio >= {f_val:.2f}")
break
return "\n".join(lines) if lines else " (no single-parameter fix found)"
def _validate(self) -> None:
"""Validate resolved parameters."""
if self.num_turns < 1:
raise ValueError("num_turns must be >= 1.")
if self.osl < 1:
raise ValueError("osl must be >= 1.")
if self.num_sessions is not None and self.num_sessions < 1:
raise ValueError("num_sessions must be >= 1.")
if self.num_sessions is None and self.duration_s <= 0:
raise ValueError(
"Must specify either --num-sessions or --duration (> 0) for rate-based mode."
)
if not (0 <= self.shared_system_prompt_ratio <= 1):
raise ValueError("shared_system_prompt_ratio must be in [0, 1].")
if self.think_time < 0:
raise ValueError("think_time must be >= 0.")
if (
self.num_turns == 1
and self.shared_system_prompt_ratio == 0
and self.hit_rate is not None
and self.hit_rate > 1e-9
):
raise ValueError(
f"Cannot achieve hit_rate={self.hit_rate} with num_turns=1 and "
f"shared_system_prompt_ratio=0. There is no caching source "
f"(no multi-turn history, no shared prefix). "
f"Set shared_system_prompt_ratio > 0 to enable cross-session "
f"prefix caching, or use num_turns > 1 for multi-turn caching."
)
if self.concurrency is None and self.request_rate is None:
raise ValueError("Must specify either --concurrency or --request-rate.")
if self.concurrency is not None and self.request_rate is not None:
raise ValueError("Cannot specify both --concurrency and --request-rate.")
if self.concurrency is not None and self.concurrency < 1:
raise ValueError("concurrency must be >= 1.")
if self.request_rate is not None and self.request_rate <= 0:
raise ValueError("request_rate must be > 0.")
if self.ramp_interval < 0:
if self.concurrency is not None:
if self.think_time > 0:
self.ramp_interval = self.think_time / self.concurrency
else:
self.ramp_interval = 0.0
else:
self.ramp_interval = 0.0
if (
self.concurrency is not None
and self.think_time > 0
and self.num_sessions is not None
and self.num_sessions < self.concurrency * 2
):
logger.warning(
"num_sessions=%d may be too low to sustain concurrency=%d "
"with think_time=%.1f. Consider increasing num_sessions.",
self.num_sessions,
self.concurrency,
self.think_time,
)
@property
def user_tokens(self) -> int:
return self._user_tokens
@property
def sys_tokens(self) -> int:
return self._sys_tokens
@property
def shared_s(self) -> int:
return int(round(self._sys_tokens * self.shared_system_prompt_ratio))
@property
def unique_s(self) -> int:
return self._sys_tokens - self.shared_s
def turn_input_tokens(self, k: int) -> int:
"""Total input tokens at turn k (1-indexed)."""
return self._sys_tokens + k * self._user_tokens + (k - 1) * self.osl
@property
def effective_isl(self) -> float:
n = self.num_turns
return (
self._sys_tokens + self._user_tokens * (n + 1) / 2 + self.osl * (n - 1) / 2
)
@property
def effective_h(self) -> float:
f = self.shared_system_prompt_ratio
n = self.num_turns
avg_new = (1 - f) * self._sys_tokens / n + self._user_tokens
isl = self.effective_isl
return 1.0 - avg_new / isl if isl > 0 else 0.0
def summary(self) -> dict:
per_turn = []
for k in range(1, self.num_turns + 1):
total = self.turn_input_tokens(k)
if k == 1:
cached = int(round(self._sys_tokens * self.shared_system_prompt_ratio))
else:
cached = (
self._sys_tokens + (k - 1) * self._user_tokens + (k - 1) * self.osl
)
new = total - cached
h_k = cached / total if total > 0 else 0.0
per_turn.append(
{
"turn": k,
"total": total,
"cached": cached,
"new": new,
"hit_rate": round(h_k, 4),
}
)
return {
"num_sessions": self.num_sessions,
"duration_s": self.duration_s,
"num_turns": self.num_turns,
"osl": self.osl,
"think_time": self.think_time,
"concurrency": self.concurrency,
"request_rate": self.request_rate,
"shared_system_prompt_ratio": self.shared_system_prompt_ratio,
"user_tokens_per_turn": self._user_tokens,
"system_prompt_tokens": self._sys_tokens,
"shared_system_prompt": self.shared_s,
"unique_system_prompt": self.unique_s,
"effective_isl": round(self.effective_isl, 1),
"effective_hit_rate": round(self.effective_h, 4),
"per_turn": per_turn,
}
def print_summary(self) -> None:
s = self.summary()
print("=" * 70)
print("Workload Spec (resolved)")
print("=" * 70)
if s["num_sessions"] is not None:
print(f" Sessions (N_s): {s['num_sessions']}")
else:
print(" Sessions (N_s): unlimited (duration-based)")
if s["duration_s"] > 0:
print(f" Duration: {s['duration_s']}s")
print(f" Turns per session (N_t): {s['num_turns']}")
print(f" User tokens/turn (u): {s['user_tokens_per_turn']}")
print(
f" System prompt (s): {s['system_prompt_tokens']} "
f"(shared={s['shared_system_prompt']}, unique={s['unique_system_prompt']})"
)
print(f" Output tokens (o): {s['osl']}")
print(f" Think time: {s['think_time']}s")
if self.concurrency is not None:
print(f" Concurrency (C): {self.concurrency}")
print(f" Ramp interval: {self.ramp_interval:.3f}s")
if self.request_rate is not None:
print(f" Request rate (QPS): {self.request_rate}")
print(f" Shared sys prompt ratio: {s['shared_system_prompt_ratio']}")
print(f" Effective avg ISL: {s['effective_isl']}")
print(f" Effective avg hit rate: {s['effective_hit_rate']:.1%}")
print("-" * 70)
print(f" {'Turn':<6} {'Total':<8} {'Cached':<8} {'New':<8} {'Hit Rate':<10}")
for t in s["per_turn"]:
print(
f" {t['turn']:<6} {t['total']:<8} {t['cached']:<8} "
f"{t['new']:<8} {t['hit_rate']:.1%}"
)
print("=" * 70)
@@ -0,0 +1,154 @@
"""Reporting and result persistence for the benchmark."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from statistics import mean
from typing import Optional
from ray.llm._internal.serve.benchmark.metrics import (
percentile,
serialize_raw_metrics,
summarize_metrics,
)
from ray.llm._internal.serve.benchmark.models import TurnMetric, WorkloadSpec
logger = logging.getLogger(__name__)
def report_results(
metrics: list[TurnMetric],
spec: WorkloadSpec,
bench_elapsed_s: float,
first_chunk_threshold: int = 16,
save_path: Optional[str] = None,
warmup_s: float = 0.0,
discarded_warmup_requests: int = 0,
) -> None:
"""Print and optionally save benchmark results."""
if not metrics:
print("No metrics collected.")
return
all_ttft = [m.ttft_ms for m in metrics]
all_fc = [m.fc_ms for m in metrics]
all_itl = [v for m in metrics for v in m.itl_ms_list]
all_latency = [m.e2e_latency_ms for m in metrics]
all_input = [m.input_tokens for m in metrics]
all_output = [m.output_tokens for m in metrics]
total_output_tokens = sum(all_output)
throughput = total_output_tokens / bench_elapsed_s if bench_elapsed_s > 0 else 0
print()
print("=" * 70)
print("BENCHMARK RESULTS")
print("=" * 70)
print(f" Total requests: {len(metrics)}")
print(f" Unique sessions: {len({m.session_id for m in metrics})}")
print(f" Duration: {bench_elapsed_s:.1f}s")
if warmup_s > 0:
print(f" Warm-up excluded: {warmup_s:.1f}s")
if discarded_warmup_requests > 0:
print(f" Warm-up requests: {discarded_warmup_requests} (discarded)")
print(f" Throughput: {throughput:.1f} output tok/s")
print(f" Request rate: {len(metrics) / bench_elapsed_s:.1f} req/s")
print(
f" Avg input tokens: {mean(all_input):.0f} "
f"(target ISL: {spec.effective_isl:.0f})"
)
print(f" Avg output tokens: {mean(all_output):.0f} (target OSL: {spec.osl})")
print()
fc_label = f"FC({first_chunk_threshold})"
print(" Latency Statistics:")
for name, values in [
("TTFT", all_ttft),
(fc_label, all_fc),
("ITL", all_itl),
("Latency", all_latency),
]:
if not values:
continue
print(
f" {name:>8}: avg={mean(values):>8.1f}ms "
f"P50={percentile(values, 50):>8.1f}ms "
f"P90={percentile(values, 90):>8.1f}ms "
f"P99={percentile(values, 99):>8.1f}ms"
)
print()
print(" Per-Turn Breakdown:")
print(
f" {'Turn':<6} {'Count':<7} {'Avg ISL':<9} {'Avg TTFT':<10} "
f"{'Avg FC':<10} {'Avg ITL':<10} {'Avg Lat':<10}"
)
for t in range(spec.num_turns):
turn_metrics = [m for m in metrics if m.turn == t]
if not turn_metrics:
continue
t_ttft = mean([m.ttft_ms for m in turn_metrics])
t_fc = mean([m.fc_ms for m in turn_metrics])
t_itl_all = [v for m in turn_metrics for v in m.itl_ms_list]
t_itl = mean(t_itl_all) if t_itl_all else 0.0
t_lat = mean([m.e2e_latency_ms for m in turn_metrics])
t_isl = mean([m.input_tokens for m in turn_metrics])
print(
f" {t + 1:<6} {len(turn_metrics):<7} {t_isl:<9.0f} "
f"{t_ttft:<10.1f} {t_fc:<10.1f} {t_itl:<10.1f} {t_lat:<10.1f}"
)
print("=" * 70)
if save_path:
stats = summarize_metrics(metrics, bench_elapsed_s)
result = {
"config": {
"concurrency": spec.concurrency,
"request_rate": spec.request_rate,
},
"spec": spec.summary(),
"first_chunk_threshold": first_chunk_threshold,
"benchmark": {
"total_requests": len(metrics),
"duration_s": round(bench_elapsed_s, 2),
"warmup_s": round(warmup_s, 2),
"discarded_warmup_requests": discarded_warmup_requests,
},
"stats": {
("measured_request_rate" if k == "request_rate" else k): v
for k, v in stats.items()
if k not in ("requests", "elapsed_s")
},
"per_turn": [],
"raw_metrics": serialize_raw_metrics(metrics),
}
for t in range(spec.num_turns):
turn_metrics = [m for m in metrics if m.turn == t]
if not turn_metrics:
continue
t_ttft = [m.ttft_ms for m in turn_metrics]
t_fc = [m.fc_ms for m in turn_metrics]
t_itl = [v for m in turn_metrics for v in m.itl_ms_list]
t_isl = [m.input_tokens for m in turn_metrics]
result["per_turn"].append(
{
"turn": t + 1,
"count": len(turn_metrics),
"avg_isl": round(mean(t_isl), 1),
"avg_ttft_ms": round(mean(t_ttft), 2),
"avg_fc_ms": round(mean(t_fc), 2),
"avg_itl_ms": round(mean(t_itl), 2) if t_itl else 0,
"p50_fc_ms": round(percentile(t_fc, 50), 2),
"p99_ttft_ms": round(percentile(t_ttft, 99), 2),
"p99_fc_ms": round(percentile(t_fc, 99), 2),
"p99_itl_ms": (round(percentile(t_itl, 99), 2) if t_itl else 0),
}
)
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
with open(save_path, "w") as f:
json.dump(result, f, indent=2)
logger.info("Results saved to %s", save_path)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
"""Text generation and conversation management for the benchmark."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional
import numpy as np
from ray.llm._internal.serve.benchmark.models import WorkloadSpec
if TYPE_CHECKING:
from transformers import PreTrainedTokenizerBase
logger = logging.getLogger(__name__)
class Conversation:
"""A single multi-turn conversation with a unique session ID."""
def __init__(
self,
session_id: str,
system_prompt: str,
user_messages: list[str],
num_turns: int,
):
self.session_id = session_id
self.system_prompt = system_prompt
self.user_messages = user_messages
self.num_turns = num_turns
self._assistant_responses: list[str] = []
def get_turn_messages(self, turn_idx: int) -> list[dict[str, str]]:
"""Build the messages list for turn `turn_idx` (0-indexed)."""
messages: list[dict[str, str]] = []
if self.system_prompt:
messages.append({"role": "system", "content": self.system_prompt})
for i in range(turn_idx + 1):
messages.append({"role": "user", "content": self.user_messages[i]})
if i < turn_idx:
if i < len(self._assistant_responses):
messages.append(
{"role": "assistant", "content": self._assistant_responses[i]}
)
else:
messages.append({"role": "assistant", "content": "(placeholder)"})
return messages
def inject_assistant_response(self, turn_idx: int, content: str) -> None:
"""Record the server's response for turn `turn_idx`."""
if turn_idx == len(self._assistant_responses):
self._assistant_responses.append(content)
elif turn_idx < len(self._assistant_responses):
self._assistant_responses[turn_idx] = content
else:
raise ValueError(
f"Cannot inject response for turn {turn_idx}: "
f"only {len(self._assistant_responses)} responses recorded."
)
class TextGenerator:
"""Generates random text with exact token counts using a tokenizer."""
def __init__(self, tokenizer: "PreTrainedTokenizerBase"):
self._tokenizer = tokenizer
self._vocab_size = tokenizer.vocab_size
logger.info(
"TextGenerator using tokenizer (vocab_size=%d) for exact token counts.",
self._vocab_size,
)
def generate(self, num_tokens: int) -> str:
if num_tokens <= 0:
return ""
return self._generate_exact(num_tokens)
def generate_token_ids(self, num_tokens: int) -> list[int]:
if num_tokens <= 0:
return []
return np.random.randint(0, self._vocab_size, size=num_tokens).tolist()
def _generate_exact(self, target_tokens: int) -> str:
tokenizer = self._tokenizer
token_ids = np.random.randint(
0, self._vocab_size, size=target_tokens + 20
).tolist()
text = tokenizer.decode(token_ids, skip_special_tokens=True)
actual_ids = tokenizer.encode(text, add_special_tokens=False)
actual_len = len(actual_ids)
if actual_len == target_tokens:
return text
if actual_len > target_tokens:
trimmed_ids = actual_ids[:target_tokens]
text = tokenizer.decode(trimmed_ids, skip_special_tokens=True)
final_len = len(tokenizer.encode(text, add_special_tokens=False))
if final_len != target_tokens:
text = self._binary_search_trim(actual_ids, target_tokens)
return text
deficit = target_tokens - actual_len
extra_ids = np.random.randint(0, self._vocab_size, size=deficit + 20).tolist()
extra_text = tokenizer.decode(extra_ids, skip_special_tokens=True)
combined = text + " " + extra_text
combined_ids = tokenizer.encode(combined, add_special_tokens=False)
if len(combined_ids) >= target_tokens:
trimmed = combined_ids[:target_tokens]
text = tokenizer.decode(trimmed, skip_special_tokens=True)
final_len = len(tokenizer.encode(text, add_special_tokens=False))
if final_len != target_tokens:
text = self._binary_search_trim(combined_ids, target_tokens)
return text
while len(tokenizer.encode(combined, add_special_tokens=False)) < target_tokens:
combined += " hello"
combined_ids = tokenizer.encode(combined, add_special_tokens=False)
return self._binary_search_trim(combined_ids, target_tokens)
def _binary_search_trim(self, token_ids: list[int], target: int) -> str:
tokenizer = self._tokenizer
lo, hi = target, len(token_ids)
best_text = tokenizer.decode(token_ids[:target], skip_special_tokens=True)
while lo <= hi:
mid = (lo + hi) // 2
text = tokenizer.decode(token_ids[:mid], skip_special_tokens=True)
actual = len(tokenizer.encode(text, add_special_tokens=False))
if actual == target:
return text
elif actual < target:
lo = mid + 1
else:
hi = mid - 1
best_text = text
for n in range(target, len(token_ids) + 1):
text = tokenizer.decode(token_ids[:n], skip_special_tokens=True)
if len(tokenizer.encode(text, add_special_tokens=False)) == target:
return text
return best_text
def conversation_factory(
session_idx: int,
spec: WorkloadSpec,
shared_system_text: str,
text_gen: Optional[TextGenerator],
) -> Conversation:
"""Create a single conversation on-demand (lazy generation)."""
session_id = f"session-{session_idx:06d}"
if spec.unique_s > 0 and text_gen is not None:
unique_text = text_gen.generate(spec.unique_s)
system_prompt = shared_system_text + " " + unique_text
else:
system_prompt = shared_system_text
user_messages = (
[text_gen.generate(spec.user_tokens) for _ in range(spec.num_turns)]
if text_gen is not None
else ["" for _ in range(spec.num_turns)]
)
return Conversation(
session_id=session_id,
system_prompt=system_prompt,
user_messages=user_messages,
num_turns=spec.num_turns,
)
@@ -0,0 +1,75 @@
"""Single-turn execution primitive for the benchmark.
This module provides the pure core of turn execution: send an HTTP request,
build a TurnMetric, and inject the response. It has NO side effects — callers
are responsible for inflight tracking, metric recording, and queue management.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
import aiohttp
from ray.llm._internal.serve.benchmark.http_client import send_chat_completion
from ray.llm._internal.serve.benchmark.models import TurnMetric, TurnResult
from ray.llm._internal.serve.benchmark.text_gen import Conversation
@dataclass
class TurnOutcome:
"""Result of executing a single benchmark turn."""
metric: TurnMetric
result: TurnResult
async def execute_single_turn(
http_session: aiohttp.ClientSession,
conv: Conversation,
turn_idx: int,
base_url: str,
model: str,
max_tokens: int,
bench_start_ns: int,
first_chunk_threshold: int = 16,
api_key: Optional[str] = None,
) -> TurnOutcome:
"""Execute a single benchmark turn: HTTP call, build metric, inject response.
This is the pure core shared by all three benchmark engines (concurrency,
rate-based, interactive). The caller handles inflight tracking, warmup
filtering, measurement windows, and queue re-enqueue.
"""
messages = conv.get_turn_messages(turn_idx)
req_start_ns = time.perf_counter_ns()
result = await send_chat_completion(
session=http_session,
base_url=base_url,
model=model,
messages=messages,
session_id=conv.session_id,
max_tokens=max_tokens,
first_chunk_threshold=first_chunk_threshold,
api_key=api_key,
)
metric = TurnMetric(
session_id=conv.session_id,
turn=turn_idx,
ttft_ms=result.ttft_ms,
fc_ms=result.fc_ms,
itl_ms=result.itl_ms,
e2e_latency_ms=result.e2e_latency_ms,
input_tokens=result.input_tokens,
output_tokens=result.output_tokens,
start_time_ms=(req_start_ns - bench_start_ns) / 1e6,
itl_ms_list=result.itl_ms_list,
)
conv.inject_assistant_response(turn_idx, result.generated_text)
return TurnOutcome(metric=metric, result=result)
+111
View File
@@ -0,0 +1,111 @@
import os
ALLOW_NEW_PLACEMENT_GROUPS_IN_DEPLOYMENT = int(
os.getenv("RAYLLM_ALLOW_NEW_PLACEMENT_GROUPS_IN_DEPLOYMENT", "1")
)
# Timeout before download in multiplex deployment fails. <=0 means no timeout.
DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S = float(
os.getenv("DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S", "30")
)
if DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S <= 0:
DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S = None
# Number of retries for downloading a model in multiplex deployment.
DEFAULT_MULTIPLEX_DOWNLOAD_TRIES = int(
os.getenv("DEFAULT_MULTIPLEX_DOWNLOAD_RETRIES", "3")
)
# If true, a default runtime_env will be injected to import rayllm on worker startup.
# This is a startup time optimization to avoid the latency penalty of sequentially
# importing rayllm in multiple layers of worker processes.
ENABLE_WORKER_PROCESS_SETUP_HOOK = (
os.environ.get("RAYLLM_ENABLE_WORKER_PROCESS_SETUP_HOOK", "1") == "1"
)
CLOUD_OBJECT_MISSING_EXPIRE_S = 30
CLOUD_OBJECT_EXISTS_EXPIRE_S = 60 * 60
# Sentinel object used to indicate that a LoRA adapter config file is missing.
LORA_ADAPTER_CONFIG_NAME = "adapter_config.json"
DEFAULT_HEALTH_CHECK_PERIOD_S = int(
os.getenv("RAY_SERVE_LLM_DEFAULT_HEALTH_CHECK_PERIOD_S", "10")
)
DEFAULT_HEALTH_CHECK_TIMEOUT_S = int(
os.getenv("RAY_SERVE_LLM_DEFAULT_HEALTH_CHECK_TIMEOUT_S", "10")
)
DEFAULT_MAX_ONGOING_REQUESTS = int(
os.getenv("RAY_SERVE_LLM_DEFAULT_MAX_ONGOING_REQUESTS", str(int(1e9)))
)
DEFAULT_MAX_REPLICAS = int(os.getenv("RAY_SERVE_LLM_DEFAULT_MAX_REPLICAS", "10"))
DEFAULT_MAX_TARGET_ONGOING_REQUESTS = int(
os.getenv("RAY_SERVE_LLM_DEFAULT_MAX_TARGET_ONGOING_REQUESTS", str(int(1e9)))
)
ENGINE_START_TIMEOUT_S = int(os.getenv("RAYLLM_ENGINE_START_TIMEOUT_S", str(60 * 60)))
MIN_NUM_TOPLOGPROBS_ALLOWED = 0
MAX_NUM_TOPLOGPROBS_ALLOWED = 5
MODEL_RESPONSE_BATCH_TIMEOUT_MS = float(
os.getenv("RAYLLM_MODEL_RESPONSE_BATCH_TIMEOUT_MS", "50")
)
RAYLLM_ENABLE_REQUEST_PROMPT_LOGS = (
os.environ.get("RAYLLM_ENABLE_REQUEST_PROMPT_LOGS", "1") == "1"
)
RAYLLM_GUIDED_DECODING_BACKEND = os.environ.get(
"RAYLLM_GUIDED_DECODING_BACKEND", "xgrammar"
)
RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING = (
os.environ.get("RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING", "0") == "1"
)
MAX_NUM_STOPPING_SEQUENCES = int(os.getenv("RAYLLM_MAX_NUM_STOPPING_SEQUENCES", "8"))
ENV_VARS_TO_PROPAGATE = {
"HUGGING_FACE_HUB_TOKEN",
"HF_TOKEN",
}
# timeout in 10 minutes. Streaming can take longer than 3 min
DEFAULT_LLM_ROUTER_HTTP_TIMEOUT = float(
os.environ.get("RAY_SERVE_LLM_ROUTER_HTTP_TIMEOUT", 600)
)
ENABLE_VERBOSE_TELEMETRY = bool(int(os.getenv("RAYLLM_ENABLE_VERBOSE_TELEMETRY", "0")))
RAYLLM_VLLM_ENGINE_CLS_ENV = "RAYLLM_VLLM_ENGINE_CLS"
# The ratio of number of router replicas to number of model replicas.
# Default to 2 meaning that there are 2 router replicas for every model replica.
DEFAULT_ROUTER_TO_MODEL_REPLICA_RATIO = float(
os.getenv("RAY_SERVE_LLM_ROUTER_TO_MODEL_REPLICA_RATIO", "2")
)
DEFAULT_LLM_ROUTER_MIN_REPLICAS = int(
os.environ.get("RAY_SERVE_LLM_ROUTER_MIN_REPLICAS", 2)
)
DEFAULT_LLM_ROUTER_INITIAL_REPLICAS = int(
os.environ.get("RAY_SERVE_LLM_ROUTER_INITIAL_REPLICAS", 2)
)
DEFAULT_LLM_ROUTER_MAX_REPLICAS = int(
os.environ.get("RAY_SERVE_LLM_ROUTER_MAX_REPLICAS", 1000)
)
DEFAULT_LLM_ROUTER_TARGET_ONGOING_REQUESTS = int(
os.environ.get(
"RAY_SERVE_LLM_ROUTER_TARGET_ONGOING_REQUESTS",
DEFAULT_MAX_TARGET_ONGOING_REQUESTS,
)
)
# Minimum interval (seconds) between full tracebacks for fatal engine errors
DEFAULT_FATAL_ERROR_COOLDOWN_S = float(
os.getenv("RAY_SERVE_LLM_ERROR_LOG_COOLDOWN_S", "10")
)
# HOME DIR
RAYLLM_HOME_DIR = os.environ.get("RAYLLM_HOME_DIR", os.path.expanduser("~/.ray/llm"))
@@ -0,0 +1,305 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from typing_extensions import Annotated
import ray.util.accelerators.accelerators as accelerators
from ray._private.accelerators.tpu import get_chips_per_host
from ray.llm._internal.serve.observability.logging import get_logger
from ray.util.placement_group import PlacementGroup, placement_group
from ray.util.tpu import (
get_tpu_version_from_type,
slice_placement_group,
)
logger = get_logger(__name__)
AcceleratorType = Enum("AcceleratorType", vars(accelerators))
# Set of TPU string values from Ray's known accelerators.
TPU_ACCELERATOR_VALUES = {
member.value
for name, member in AcceleratorType.__members__.items()
if name.startswith("GOOGLE_TPU")
}
def format_ray_accelerator_resource(accelerator_type_str: str) -> str:
"""Formats the accelerator type into a Ray custom resource string."""
return f"accelerator_type:{accelerator_type_str}"
def infer_hardware_kind_from_bundles(
placement_group_config: Optional[Dict[str, Any]]
) -> Optional[str]:
"""Inspects placement group bundles and returns the inferred hardware kind."""
if not placement_group_config:
return None
bundle_per_worker = placement_group_config.get("bundle_per_worker") or {}
bundles = placement_group_config.get("bundles") or []
all_bundles = [bundle_per_worker] + bundles
if any(b.get("TPU", 0) > 0 for b in all_bundles):
return "tpu"
if any(b.get("GPU", 0) > 0 for b in all_bundles):
return "gpu"
# If a config was provided but lacks GPUs or TPUs, it is a CPU deployment
return "cpu"
class AcceleratorConfig(BaseModel):
kind: str
class CPUConfig(AcceleratorConfig):
kind: Literal["cpu"] = "cpu"
class GPUConfig(AcceleratorConfig):
kind: Literal["gpu"] = "gpu"
class TPUConfig(AcceleratorConfig):
kind: Literal["tpu"] = "tpu"
topology: Optional[str] = None
AnyAcceleratorConfig = Annotated[
Union[CPUConfig, GPUConfig, TPUConfig],
Field(discriminator="kind"),
]
class AcceleratorBackend(ABC):
@abstractmethod
def default_bundles(
self,
*,
num_devices: int,
accelerator_type_str: Optional[str] = None,
) -> List[Dict[str, float]]:
pass
@abstractmethod
def create_placement_group(
self,
*,
bundles: List[Dict[str, float]],
strategy: str,
name: str,
accelerator_type_str: Optional[str] = None,
) -> PlacementGroup:
pass
@property
def requires_deferred_placement_group(self) -> bool:
"""
If True, Ray Serve will not provision a placement group for the deployment.
Instead, creation is deferred to the replica at runtime.
Defaults to False.
"""
return False
@property
@abstractmethod
def requires_remote_initialization(self) -> bool:
"""Boolean indicating whether this backend needs a remote Ray task to query hardware during init."""
pass
@abstractmethod
def get_remote_options(self, accelerator_type_str: str = None) -> Dict[str, Any]:
"""Returns the hardware-specific kwargs for ray.remote().options()."""
pass
def shutdown(self) -> None:
"""Release any resources owned by this backend. Idempotent."""
return
class CPUAccelerator(AcceleratorBackend):
# stateless — no __init__
def default_bundles(
self, *, num_devices: int, accelerator_type_str: Optional[str] = None
):
return [{"CPU": 1} for _ in range(num_devices)]
def create_placement_group(
self,
*,
bundles: List[Dict[str, float]],
strategy: str,
name: str,
accelerator_type_str: Optional[str] = None,
):
return placement_group(bundles=bundles, strategy=strategy, name=name)
@property
def requires_remote_initialization(self) -> bool:
return False
def get_remote_options(self, accelerator_type_str: str = None):
return {}
class GPUAccelerator(AcceleratorBackend):
# stateless — no __init__
def default_bundles(
self, *, num_devices: int, accelerator_type_str: Optional[str] = None
):
bundle = {"GPU": 1}
if accelerator_type_str:
bundle[format_ray_accelerator_resource(accelerator_type_str)] = 0.001
return [bundle.copy() for _ in range(num_devices)]
def create_placement_group(
self,
*,
bundles: List[Dict[str, float]],
strategy: str,
name: str,
accelerator_type_str: Optional[str] = None,
):
return placement_group(bundles=bundles, strategy=strategy, name=name)
@property
def requires_remote_initialization(self) -> bool:
return True
def get_remote_options(self, accelerator_type_str: str = None):
options = {"num_gpus": 0.001}
if accelerator_type_str:
options["accelerator_type"] = accelerator_type_str
return options
class TPUAccelerator(AcceleratorBackend):
def __init__(self, config: TPUConfig):
self._config = config
self._slice_pg_wrapper = None
def default_bundles(
self, *, num_devices: int, accelerator_type_str: Optional[str] = None
):
if not self._config.topology:
# Fallback to per-chip bundles if no topology is specified
bundle = {"TPU": 1}
if accelerator_type_str:
bundle[format_ray_accelerator_resource(accelerator_type_str)] = 0.001
return [bundle.copy() for _ in range(num_devices)]
# Topology is specified, compute per-host bundles
if not accelerator_type_str:
raise ValueError(
"`accelerator_type` must be specified when `topology` is present "
"in order to compute TPU resource requirements."
)
version = get_tpu_version_from_type(accelerator_type_str)
chips_per_host = get_chips_per_host(self._config.topology, version)
if num_devices > chips_per_host and num_devices % chips_per_host != 0:
raise ValueError(
f"num_devices ({num_devices}) must be a multiple of "
f"chips_per_host ({chips_per_host}) for TPU topologies."
)
num_hosts = max(1, num_devices // chips_per_host)
tpu_resources = min(num_devices, chips_per_host)
bundle = {"TPU": tpu_resources}
bundle[format_ray_accelerator_resource(accelerator_type_str)] = 0.001
return [bundle.copy() for _ in range(num_hosts)]
def create_placement_group(
self,
*,
bundles: List[Dict[str, float]],
strategy: str,
name: str,
accelerator_type_str: Optional[str] = None,
) -> PlacementGroup:
if not self._config.topology:
return placement_group(bundles=bundles, strategy=strategy, name=name)
if not accelerator_type_str:
raise ValueError(
"accelerator_type must be provided for TPU slice provisioning."
)
version = get_tpu_version_from_type(accelerator_type_str)
if bundles:
# Filter for bundles that actually specify TPU resources
tpu_bundles = [b for b in bundles if b.get("TPU", 0) > 0]
if not tpu_bundles:
worker_bundle = {"TPU": 1}
else:
worker_bundle = tpu_bundles[0]
# Ensure all TPU bundles are homogeneous
if any(b != worker_bundle for b in tpu_bundles):
raise ValueError(
"Heterogeneous TPU bundles are not supported when `topology` is set. "
"A multi-host TPU slice requires homogeneous resource bundles across all workers. "
"Please use `bundle_per_worker` in `placement_group_config` to define uniform worker resources."
)
else:
# Default to 1 TPU per bundle.
worker_bundle = {"TPU": 1}
if self._slice_pg_wrapper is not None:
logger.debug(
"Existing TPU slice PG found. Shutting it down before creating a new one."
)
self.shutdown()
self._slice_pg_wrapper = slice_placement_group(
topology=self._config.topology,
accelerator_version=version,
resources_per_bundle=worker_bundle,
strategy=strategy,
name=name,
)
return self._slice_pg_wrapper.placement_group
@property
def requires_deferred_placement_group(self) -> bool:
"""
If a TPU topology is specified, we defer PG creation so the replica can
provision a `SlicePlacementGroup` at runtime. This ensures multi-host
TPU slices are gang-scheduled atomically according to their physical
topology rather than fragmented across the cluster.
"""
return bool(self._config.topology)
@property
def requires_remote_initialization(self) -> bool:
return True
def get_remote_options(self, accelerator_type_str: str = None):
# The PlacementGroupSchedulingStrategy natively handles routing the task to
# the correct hardware. We omit TPU resource requests to avoid consuming
# chips that the model engine workers must use.
options: Dict[str, Any] = {"resources": {}}
if accelerator_type_str:
# Pin the task to the TPU accelerator to avoid scheduling on a CPU bundle.
options["label_selector"] = {
"ray.io/accelerator-type": accelerator_type_str
}
return options
def shutdown(self):
if self._slice_pg_wrapper is not None:
try:
logger.info("Shutting down TPU slice PG for server replica.")
self._slice_pg_wrapper.shutdown()
except Exception as e:
logger.warning(f"Failed to shut down TPU slice PG: {e}")
finally:
self._slice_pg_wrapper = None
@@ -0,0 +1,658 @@
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Dict,
Optional,
TypeVar,
Union,
)
from pydantic import (
BaseModel,
Field,
PositiveInt,
PrivateAttr,
field_validator,
model_validator,
)
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.callbacks.base import (
CallbackBase,
CallbackConfig,
)
from ray.llm._internal.common.utils.cloud_utils import (
CloudMirrorConfig,
is_remote_path,
)
from ray.llm._internal.common.utils.download_utils import (
STREAMING_LOAD_FORMATS,
NodeModelDownloadable,
)
from ray.llm._internal.common.utils.import_utils import load_class, try_import
from ray.llm._internal.serve.constants import (
DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S,
DEFAULT_MULTIPLEX_DOWNLOAD_TRIES,
MODEL_RESPONSE_BATCH_TIMEOUT_MS,
)
from ray.llm._internal.serve.core.configs.accelerators import (
TPU_ACCELERATOR_VALUES,
AcceleratorType,
AnyAcceleratorConfig,
CPUConfig,
GPUConfig,
TPUConfig,
infer_hardware_kind_from_bundles,
)
from ray.llm._internal.serve.engines.vllm.kv_transfer.factory import (
KVConnectorBackendFactory,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.serve._private.config import DeploymentConfig, handle_num_replicas_auto
if TYPE_CHECKING:
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
BaseConnectorBackend,
)
transformers = try_import("transformers")
ModelT = TypeVar("ModelT", bound=BaseModel)
logger = get_logger(__name__)
class ServeMultiplexConfig(BaseModelExtended):
max_num_models_per_replica: PositiveInt = Field(
..., description="The maximum number of models to be loaded on each replica."
)
download_timeout_s: Optional[float] = Field(
DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S,
description="How much time the download subprocess has to download a single LoRA before a timeout. None means no timeout.",
)
max_download_tries: int = Field(
DEFAULT_MULTIPLEX_DOWNLOAD_TRIES,
description="The maximum number of download retries.",
)
class InputModality(str, Enum):
text = "text"
image = "image"
class LLMEngine(str, Enum):
"""Enum that represents an LLMEngine."""
vLLM = "vLLM"
class LoraConfig(BaseModelExtended):
dynamic_lora_loading_path: Optional[str] = Field(
default=None,
description="Cloud storage path where LoRA adapter weights are stored.",
)
max_num_adapters_per_replica: PositiveInt = Field(
default=16,
description="The maximum number of adapters to load on each replica.",
)
download_timeout_s: Optional[float] = Field(
DEFAULT_MULTIPLEX_DOWNLOAD_TIMEOUT_S,
description=(
"How much time the download subprocess has to download a single "
"LoRA before a timeout. None means no timeout."
),
)
max_download_tries: int = Field(
DEFAULT_MULTIPLEX_DOWNLOAD_TRIES,
description="The maximum number of download retries.",
)
@field_validator("dynamic_lora_loading_path")
def validate_dynamic_lora_loading_path(cls, value: Optional[str]):
if value is None:
return value
assert is_remote_path(value), (
"Only AWS S3, Google Cloud Storage, and Azure Storage are supported. The "
'dynamic_lora_loading_path must start with "s3://", "gs://", "abfss://", or "azure://". '
f'Got "{value}" instead.'
)
return value.rstrip("/")
class ModelLoadingConfig(BaseModelExtended):
model_id: str = Field(
description="The ID that should be used by end users to access this model.",
)
model_source: Optional[Union[str, CloudMirrorConfig]] = Field(
default=None,
description=(
"Where to obtain the model weights from. "
"Should be a HuggingFace model ID, S3 mirror config, GCS mirror config, "
"or a local path. When omitted, defaults to the model_id as a "
"HuggingFace model ID."
),
)
tokenizer_source: Optional[str] = Field(
default=None,
description=(
"Where to obtain the tokenizer from. If None, tokenizer is "
"obtained from the model source. Only HuggingFace IDs are "
"supported for now."
),
)
EngineConfigType = Union[None, "VLLMEngineConfig"] # noqa: F821
class LLMConfig(BaseModelExtended):
runtime_env: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"The runtime_env to use for the model deployment replica "
"and the engine workers."
),
)
model_loading_config: Union[Dict[str, Any], ModelLoadingConfig] = Field(
description="The settings for how to download and expose the model. Validated against ModelLoadingConfig."
)
llm_engine: str = Field(
default=LLMEngine.vLLM.value,
description=f"The LLMEngine that should be used to run the model. Only the following values are supported: {str([t.value for t in LLMEngine])}",
)
engine_kwargs: Dict[str, Any] = Field(
default={},
description=(
"Additional keyword arguments for the engine. In case of vLLM, "
"this will include all the configuration knobs they provide out "
"of the box"
),
)
accelerator_type: Optional[str] = Field(
default=None,
description=f"The type of accelerator runs the model on. Only the following values are supported: {str([t.value for t in AcceleratorType])}",
)
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. "
"Can specify either 'bundle_per_worker' (auto-replicated by tp*pp) or 'bundles' "
"(full list of resource dicts). Optionally include 'strategy' key "
"('PACK', 'STRICT_PACK', 'SPREAD', or 'STRICT_SPREAD'). "
"Example with bundle_per_worker: {'bundle_per_worker': {'CPU': 1, 'GPU': 1}, 'strategy': 'SPREAD'}. "
"Example with bundles: {'bundles': [{'CPU': 1, 'GPU': 1}] * 4, 'strategy': 'SPREAD'}."
),
)
lora_config: Optional[Union[Dict[str, Any], LoraConfig]] = Field(
default=None,
description="Settings for LoRA adapter. Validated against LoraConfig.",
)
deployment_config: Dict[str, Any] = Field(
default_factory=dict,
description="""
The Ray @server.deployment options.
Supported fields are:
`name`, `num_replicas`, `ray_actor_options`, `max_ongoing_requests`,
`autoscaling_config`, `max_queued_requests`, `user_config`,
`health_check_period_s`, `health_check_timeout_s`,
`graceful_shutdown_wait_loop_s`, `graceful_shutdown_timeout_s`,
`logging_config`, `request_router_config`.
For more details, see the `Ray Serve Documentation <https://docs.ray.io/en/latest/serve/configure-serve-deployment.html>`_.
""",
)
server_cls: Optional[Union[str, Any]] = Field(
default=None,
description="The serve class to use.(e.g., LLMServer, SGLangServer or other Server backends).",
)
@field_validator("server_cls")
@classmethod
def validate_server_cls(cls, value):
if isinstance(value, str):
return load_class(value)
return value
experimental_configs: Dict[str, Any] = Field(
default_factory=dict,
description="Experimental configurations for Ray Serve LLM. This is a "
"dictionary of key-value pairs. Current supported keys are:\n"
"- `stream_batching_interval_ms`: Ray Serve LLM batches streaming "
"requests together. This config decides how long to wait for the "
"batch before processing the requests. Defaults to "
f"{MODEL_RESPONSE_BATCH_TIMEOUT_MS}.\n"
"- `num_ingress_replicas`: The number of replicas for the router. Ray "
"Serve will take the max amount all the replicas. Default would be 2 "
"router replicas per model replica.\n",
)
log_engine_metrics: Optional[bool] = Field(
default=True,
description="Enable additional engine metrics via Ray Prometheus port.",
)
callback_config: CallbackConfig = Field(
default_factory=CallbackConfig,
description="Callback configuration to use for model initialization. Can be a string path to a class or a Callback subclass.",
)
_supports_vision: bool = PrivateAttr(False)
_model_architecture: str = PrivateAttr("UNSPECIFIED")
_engine_config: EngineConfigType = PrivateAttr(None)
_callback_instance: Optional[CallbackBase] = PrivateAttr(None)
_kv_connector_backend: Optional["BaseConnectorBackend"] = PrivateAttr(None)
def _load_hf_config(self, model_id_or_path: str, trust_remote_code: bool = False):
"""Load the HuggingFace config for a model.
Uses AutoConfig which loads the model-specific config class (e.g.
DeepseekV3Config) instead of the generic PretrainedConfig. The generic
base class can fail for models whose config.json contains fields (like
``rope_scaling``) that require model-specific post-init logic.
"""
try:
return transformers.AutoConfig.from_pretrained(
model_id_or_path, trust_remote_code=trust_remote_code
)
except Exception as e:
raise ValueError(
f"Failed to load Hugging Face config for "
f"model_id='{model_id_or_path}'. Ensure `model_id` is a valid "
f"Hugging Face repo or a local path that contains a valid "
f"`config.json` file. Original error: {repr(e)}"
) from e
def _infer_supports_vision(
self, model_id_or_path: str, trust_remote_code: bool = False
) -> None:
"""Called in llm node initializer together with other transformers calls. It
loads the model config from huggingface and sets the supports_vision
attribute based on whether the config has `vision_config`. All LVM models has
`vision_config` setup.
"""
hf_config = self._load_hf_config(
model_id_or_path, trust_remote_code=trust_remote_code
)
self._supports_vision = hasattr(hf_config, "vision_config")
def _set_model_architecture(
self,
model_id_or_path: Optional[str] = None,
model_architecture: Optional[str] = None,
trust_remote_code: bool = False,
) -> None:
"""Called in llm node initializer together with other transformers calls. It
loads the model config from huggingface and sets the model_architecture
attribute based on whether the config has `architectures`.
"""
if model_id_or_path:
hf_config = self._load_hf_config(
model_id_or_path, trust_remote_code=trust_remote_code
)
if (
hf_config
and hasattr(hf_config, "architectures")
and hf_config.architectures
):
self._model_architecture = hf_config.architectures[0]
if model_architecture:
self._model_architecture = model_architecture
def apply_checkpoint_info(
self, model_id_or_path: str, trust_remote_code: bool = False
) -> None:
"""Apply the checkpoint info to the model config."""
self._infer_supports_vision(
model_id_or_path, trust_remote_code=trust_remote_code
)
self._set_model_architecture(
model_id_or_path, trust_remote_code=trust_remote_code
)
def get_or_create_callback(self) -> Optional[CallbackBase]:
"""Get or create the callback instance for this process.
This ensures one callback instance per process (singleton pattern).
The instance is cached so the same object is used across all hooks.
Returns:
Instance of class that implements Callback
""" # Return cached instance if exists
if self._callback_instance is not None:
return self._callback_instance
engine_config = self.get_engine_config()
assert engine_config is not None
pg = engine_config.get_or_create_pg()
runtime_env = engine_config.get_runtime_env_with_local_env_vars()
if self.engine_kwargs.get("load_format", None) in STREAMING_LOAD_FORMATS:
worker_node_download_model = NodeModelDownloadable.NONE
else:
worker_node_download_model = NodeModelDownloadable.MODEL_AND_TOKENIZER
# Create new instance
if isinstance(self.callback_config.callback_class, str):
callback_class = load_class(self.callback_config.callback_class)
else:
callback_class = self.callback_config.callback_class
self._callback_instance = callback_class(
raise_error_on_callback=self.callback_config.raise_error_on_callback,
llm_config=self,
ctx_kwargs={
"worker_node_download_model": worker_node_download_model,
"placement_group": pg,
"runtime_env": runtime_env,
},
**self.callback_config.callback_kwargs,
)
return self._callback_instance
@property
def supports_vision(self) -> bool:
return self._supports_vision
@property
def model_architecture(self) -> str:
return self._model_architecture
@property
def input_modality(self) -> str:
"""Returns the input modality of the model. There could be more types in the
future. Right now assumes if the model doesn't support version, it'll be text.
"""
if self.supports_vision:
return InputModality.image.value
return InputModality.text.value
@property
def model_id(self) -> str:
return self.model_loading_config.model_id
@property
def max_request_context_length(self) -> Optional[int]:
return self.engine_kwargs.get("max_model_len")
@field_validator("accelerator_type")
def validate_accelerator_type(cls, value: Optional[str]):
if value is None:
return value
# Ensure A10 is converted to A10G.
if value == "A10":
value = "A10G"
if value not in [t.value for t in AcceleratorType]:
raise ValueError(f"Unsupported accelerator type: {value}")
return value
@field_validator("llm_engine")
def validate_llm_engine(cls, value: str) -> str:
"""Validates the llm_engine string value."""
try:
# Validate the engine
LLMEngine(value)
except ValueError as e:
raise ValueError(f"Unsupported engine: {value}") from e
return value
@field_validator("deployment_config")
def validate_deployment_config(cls, value: Dict[str, Any]) -> Dict[str, Any]:
"""Validates the deployment config dictionary."""
try:
# Resolve "auto" for num_replicas before validating against DeploymentConfig
if value.get("num_replicas") == "auto":
resolved = {**value, "num_replicas": None}
_, autoscaling_config = handle_num_replicas_auto(
resolved.get("max_ongoing_requests"),
resolved.get("autoscaling_config"),
)
resolved["autoscaling_config"] = autoscaling_config
DeploymentConfig(**resolved)
else:
DeploymentConfig(**value)
except Exception as e:
raise ValueError(f"Invalid deployment config: {value}") from e
return value
@field_validator("model_loading_config")
def validate_model_loading_config(
cls, value: Union[Dict[str, Any], ModelLoadingConfig]
) -> ModelLoadingConfig:
"""Validates the model loading config dictionary."""
if isinstance(value, ModelLoadingConfig):
return value
try:
model_loading_config = ModelLoadingConfig(**value)
except Exception as e:
raise ValueError(f"Invalid model_loading_config: {value}") from e
return model_loading_config
@field_validator("lora_config")
def validate_lora_config(
cls, value: Optional[Union[Dict[str, Any], LoraConfig]]
) -> Optional[LoraConfig]:
"""Validates the lora config dictionary."""
if value is None or isinstance(value, LoraConfig):
return value
try:
lora_config = LoraConfig(**value)
except Exception as e:
raise ValueError(f"Invalid lora_config: {value}") from e
return lora_config
@field_validator("experimental_configs")
def validate_experimental_configs(cls, value: Dict[str, Any]) -> Dict[str, Any]:
"""Validates the experimental configs dictionary."""
# TODO(Kourosh): Remove this deprecation check after users have
# migrated.
if "num_router_replicas" in value:
raise ValueError(
"The 'num_router_replicas' key in experimental_configs has "
"been renamed to 'num_ingress_replicas'. Please update "
"your configuration to use 'num_ingress_replicas' instead."
)
return value
@model_validator(mode="after")
def _check_log_stats_with_metrics(self):
"""Validate that disable_log_stats isn't enabled when log_engine_metrics is enabled."""
if self.log_engine_metrics and self.engine_kwargs.get("disable_log_stats"):
raise ValueError(
"disable_log_stats cannot be set to True when log_engine_metrics is enabled. "
"Engine metrics require log stats to be enabled."
)
return self
@model_validator(mode="after")
def _resolve_and_validate_accelerator(self):
"""Resolves the accelerator configuration and validates it."""
self._resolve_accelerator_config()
self._check_accelerator_type_matches_hardware()
return self
def _resolve_accelerator_config(self) -> None:
"""Infers and populates accelerator_config if omitted by the user."""
if self.accelerator_config is not None:
return
# Infer hardware from placement_group_config bundles
inferred_kind = infer_hardware_kind_from_bundles(self.placement_group_config)
if inferred_kind == "tpu":
self.accelerator_config = TPUConfig(kind="tpu")
return
if inferred_kind == "gpu":
self.accelerator_config = GPUConfig(kind="gpu")
return
if inferred_kind == "cpu":
self.accelerator_config = CPUConfig(kind="cpu")
return
# Infer hardware from accelerator_type string
if self.accelerator_type:
accel_str = getattr(
self.accelerator_type, "value", str(self.accelerator_type)
)
if accel_str in TPU_ACCELERATOR_VALUES:
self.accelerator_config = TPUConfig(kind="tpu")
return
self.accelerator_config = GPUConfig(kind="gpu")
return
# Default to GPUConfig if not otherwise specified
self.accelerator_config = GPUConfig(kind="gpu")
def _check_accelerator_type_matches_hardware(self) -> None:
"""Validate that accelerator_type aligns with the hardware configuration."""
if isinstance(self.accelerator_config, TPUConfig):
# For TPU slices, both accelerator_type and topology must be provided.
if self.accelerator_config.topology and not self.accelerator_type:
raise ValueError(
"accelerator_type must be provided when specifying a TPU topology "
"for TPU slice provisioning."
)
if not self.accelerator_type:
return
if isinstance(self.accelerator_config, 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."
)
# Determine what hardware kind the string implies to check for kind mismatch
accel_str = getattr(self.accelerator_type, "value", str(self.accelerator_type))
expected_kind = "tpu" if accel_str in TPU_ACCELERATOR_VALUES else "gpu"
if self.accelerator_config.kind != expected_kind:
raise ValueError(
f"Hardware mismatch: accelerator_type='{self.accelerator_type}' requires a "
f"{expected_kind.upper()} backend, but the configuration resolved to a "
f"{self.accelerator_config.kind.upper()} backend. Please ensure your "
f"bundles and accelerator_type align."
)
def multiplex_config(self) -> ServeMultiplexConfig:
multiplex_config = None
if self.lora_config:
multiplex_config = ServeMultiplexConfig(
max_num_models_per_replica=self.lora_config.max_num_adapters_per_replica,
download_timeout_s=self.lora_config.download_timeout_s,
max_download_tries=self.lora_config.max_download_tries,
)
return multiplex_config
def get_engine_config(self) -> EngineConfigType:
"""Returns the engine config for the given LLM config.
LLMConfig not only has engine config but also deployment config, etc.
"""
# Note (genesu): This is important that we cache the engine config as the
# `hf_model_id` attribute on the engine config will be set based on whether
# the model is downloaded from a remote storage and will be set to the
# local path of the model. This is important for vLLM not going to Hugging
# Face to download the model again after it's already downloaded during node
# initialization step.
if self._engine_config:
return self._engine_config
if self.llm_engine == LLMEngine.vLLM:
from ray.llm._internal.serve.engines.vllm.vllm_models import (
VLLMEngineConfig,
)
self._engine_config = VLLMEngineConfig.from_llm_config(self)
else:
# Note (genesu): This should never happen because we validate the engine
# in the config.
raise ValueError(f"Unsupported engine: {self.llm_engine}")
return self._engine_config
def update_engine_kwargs(self, **kwargs: Any) -> None:
"""Update the engine_kwargs and the engine_config engine_kwargs.
This is typically called during engine starts, when certain engine_kwargs
(e.g., data_parallel_rank) become available.
"""
self.engine_kwargs.update(kwargs)
# engine_config may be created before engine starts, this makes sure
# the engine_config is updated with the latest engine_kwargs.
if self._engine_config:
self._engine_config.engine_kwargs.update(kwargs)
def setup_engine_backend(self):
self._setup_kv_connector_backend()
def _setup_kv_connector_backend(self):
"""Private method to setup kv connector depending on the local deployment state"""
# 1. validate that the backend is one of the backends supported (Nixl or LMCache)
kv_transfer_config = self.engine_kwargs.get("kv_transfer_config")
if not kv_transfer_config:
return
kv_connector = kv_transfer_config.get("kv_connector")
if not kv_connector:
raise ValueError("Connector type is not specified.")
# 2. Setup the backend using factory
kv_connector_backend = KVConnectorBackendFactory.create_backend(
kv_connector, self
)
kv_connector_backend.setup()
# 3. Stash the instance so the P/D orchestrator can reach the connector's
# coordination protocol (request shaping, peer binding, handoff
# discipline) without re-creating it. May be None on configs that never
# call setup_engine_backend(); the orchestrator falls back to the factory.
self._kv_connector_backend = kv_connector_backend
@property
def kv_connector_backend(self) -> Optional["BaseConnectorBackend"]:
"""The KV-connector backend instance created by ``setup_engine_backend``.
Returns None if no KV transfer connector is configured, or if the
backend has not been set up yet on this config copy.
"""
return self._kv_connector_backend
class DiskMultiplexConfig(BaseModelExtended):
model_id: str
max_total_tokens: Optional[int]
local_path: str
# this is a per process id assigned to the model
lora_assigned_int_id: int
@@ -0,0 +1,326 @@
"""This module contains wrapper classes for OpenAI-compatible protocol models.
Supports both vLLM and SGLang as the underlying engine. vLLM is tried first;
on ImportError, SGLang models are imported as a fallback. If neither is
installed, an ImportError is raised at import time.
"""
import uuid
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
from ray.llm._internal.common.utils.import_utils import raise_llm_engine_import_error
try:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest as _ChatCompletionRequest,
ChatCompletionResponse as _ChatCompletionResponse,
ChatCompletionStreamResponse as _ChatCompletionStreamResponse,
)
from vllm.entrypoints.openai.completion.protocol import (
CompletionRequest as _CompletionRequest,
CompletionResponse as _CompletionResponse,
CompletionStreamResponse as _CompletionStreamResponse,
)
from vllm.entrypoints.openai.engine.protocol import (
ErrorInfo as _ErrorInfo,
ErrorResponse as _ErrorResponse,
)
from vllm.entrypoints.pooling.embed.protocol import (
EmbeddingChatRequest as _EmbeddingChatRequest,
EmbeddingCompletionRequest as _EmbeddingCompletionRequest,
EmbeddingResponse as _EmbeddingResponse,
)
from vllm.entrypoints.pooling.scoring.protocol import (
ScoreResponse as _ScoreResponse,
ScoreTextRequest as _ScoreTextRequest,
)
from vllm.entrypoints.serve.tokenize.protocol import (
DetokenizeRequest as _DetokenizeRequest,
DetokenizeResponse as _DetokenizeResponse,
TokenizeChatRequest as _TokenizeChatRequest,
TokenizeCompletionRequest as _TokenizeCompletionRequest,
TokenizeResponse as _TokenizeResponse,
)
from vllm.entrypoints.speech_to_text.transcription.protocol import (
TranscriptionRequest as _TranscriptionRequest,
TranscriptionResponse as _TranscriptionResponse,
TranscriptionStreamResponse as _TranscriptionStreamResponse,
)
except ImportError as _vllm_import_error:
try:
from sglang.srt.entrypoints.openai.protocol import (
ChatCompletionRequest as _ChatCompletionRequest,
ChatCompletionResponse as _ChatCompletionResponse,
ChatCompletionStreamResponse as _ChatCompletionStreamResponse,
CompletionRequest as _CompletionRequest,
CompletionResponse as _CompletionResponse,
CompletionStreamResponse as _CompletionStreamResponse,
DetokenizeRequest as _DetokenizeRequest,
DetokenizeResponse as _DetokenizeResponse,
EmbeddingRequest as _EmbeddingCompletionRequest,
EmbeddingResponse as _EmbeddingResponse,
ScoringRequest as _ScoreTextRequest,
ScoringResponse as _ScoreResponse,
TokenizeRequest as _TokenizeCompletionRequest,
TokenizeResponse as _TokenizeResponse,
)
except ImportError as _sglang_import_error:
raise_llm_engine_import_error(_vllm_import_error, _sglang_import_error)
def _unsupported_model(name: str, feature: str = ""):
"""Create a BaseModel stub that raises NotImplementedError on instantiation."""
msg = f"{name} is not supported with the current backend." + (
f" {feature}" if feature else ""
)
class _Stub(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
def __init__(self, **kwargs):
raise NotImplementedError(msg)
_Stub.__name__ = _Stub.__qualname__ = name
return _Stub
# SGLang does not provide transcription protocol models.
_vllm_hint = "Install vLLM to use transcription endpoints."
_TranscriptionRequest = _unsupported_model("TranscriptionRequest", _vllm_hint)
_TranscriptionResponse = _unsupported_model("TranscriptionResponse", _vllm_hint)
_TranscriptionStreamResponse = _unsupported_model(
"TranscriptionStreamResponse", _vllm_hint
)
# SGLang has no equivalent to vLLM's nested ErrorResponse.error -> ErrorInfo
# pattern, so we define our own.
class _ErrorInfo(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
message: str
type: str
param: Optional[str] = None
code: int
class _ErrorResponse(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
error: _ErrorInfo
_EmbeddingChatRequest = _EmbeddingCompletionRequest
_TokenizeChatRequest = _TokenizeCompletionRequest
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
class ChatCompletionRequest(_ChatCompletionRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ChatCompletionResponse(_ChatCompletionResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ChatCompletionStreamResponse(_ChatCompletionStreamResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ErrorInfo(_ErrorInfo):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ErrorResponse(_ErrorResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
# TODO (Kourosh): Upstream
class CompletionRequest(_CompletionRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class CompletionResponse(_CompletionResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class CompletionStreamResponse(_CompletionStreamResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
# TODO (Kourosh): Upstream
class EmbeddingCompletionRequest(_EmbeddingCompletionRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class EmbeddingChatRequest(_EmbeddingChatRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class EmbeddingResponse(_EmbeddingResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class TranscriptionRequest(_TranscriptionRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
request_id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description=(
"The request_id related to this request. If the caller does "
"not set it, a random_uuid will be generated. This id is used "
"through out the inference process and return in response."
),
)
class TranscriptionResponse(_TranscriptionResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class TranscriptionStreamResponse(_TranscriptionStreamResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ScoreRequest(_ScoreTextRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class ScoreResponse(_ScoreResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class TokenizeCompletionRequest(_TokenizeCompletionRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class TokenizeChatRequest(_TokenizeChatRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class TokenizeResponse(_TokenizeResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
class DetokenizeRequest(_DetokenizeRequest):
model_config = ConfigDict(arbitrary_types_allowed=True)
class DetokenizeResponse(_DetokenizeResponse):
model_config = ConfigDict(arbitrary_types_allowed=True)
EmbeddingRequest = Union[EmbeddingCompletionRequest, EmbeddingChatRequest]
TokenizeRequest = Union[TokenizeCompletionRequest, TokenizeChatRequest]
LLMEmbeddingsResponse = Union[
AsyncGenerator[Union[EmbeddingResponse, ErrorResponse], None],
]
LLMScoreResponse = Union[
AsyncGenerator[Union[ScoreResponse, ErrorResponse], None],
]
LLMTokenizeResponse = Union[
AsyncGenerator[Union[TokenizeResponse, ErrorResponse], None],
]
LLMDetokenizeResponse = Union[
AsyncGenerator[Union[DetokenizeResponse, ErrorResponse], None],
]
LLMChatResponse = Union[
AsyncGenerator[
Union[str, ChatCompletionStreamResponse, ChatCompletionResponse, ErrorResponse],
None,
],
]
LLMCompletionsResponse = Union[
AsyncGenerator[
Union[str, CompletionStreamResponse, CompletionResponse, ErrorResponse], None
],
]
LLMTranscriptionResponse = Union[
AsyncGenerator[
Union[str, TranscriptionStreamResponse, TranscriptionResponse, ErrorResponse],
None,
],
]
# TODO: remove this class
class OpenAIHTTPException(Exception):
def __init__(
self,
status_code: int,
message: str,
type: str = "Unknown",
internal_message: Optional[str] = None,
) -> None:
self.status_code = status_code
self.message = message
self.type = type
self.internal_message = internal_message
# TODO: upstream metadata for ModelData
# Compared to vLLM this has a metadata field.
class ModelCard(BaseModel):
model_config = ConfigDict(
protected_namespaces=tuple(), arbitrary_types_allowed=True
)
id: str
object: str
owned_by: str
permission: List[str]
metadata: Dict[str, Any]
@property
def model_type(self) -> str:
return self.metadata["engine_config"]["model_type"]
class ModelList(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
data: List[ModelCard]
object: str = "list"
def to_model_metadata(
model_id: str,
model_config: "LLMConfig",
overrides: Optional[Dict[str, Any]] = None,
) -> ModelCard:
"""Creates an OpenAI-compatible ModelData object.
Args:
model_id: The ID of the model. Should contain the suffix if the model
is LoRA fine-tuned. For example:
meta-llama/Llama-2-7b-chat-hf:my_suffix:aBc1234
model_config: The model's YAML config.
overrides: should only be set for LoRA fine-tuned models. The
overrides of the fine-tuned model metadata.
Returns:
A ModelCard object.
"""
metadata = {
"model_id": model_config.model_id,
"input_modality": model_config.input_modality,
"max_request_context_length": model_config.max_request_context_length,
}
if overrides:
metadata.update(overrides)
return ModelCard(
id=model_id,
object="model",
owned_by="organization-owner",
permission=[],
metadata=metadata,
)
@@ -0,0 +1,333 @@
import abc
from typing import TYPE_CHECKING, Any, AsyncGenerator, Optional, Union
from ray.llm._internal.serve.core.configs.llm_config import (
DiskMultiplexConfig,
LLMConfig,
)
from ray.llm._internal.serve.core.protocol import RawRequestInfo
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
DetokenizeRequest,
DetokenizeResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorResponse,
TokenizeRequest,
TokenizeResponse,
TranscriptionRequest,
TranscriptionResponse,
)
class LLMEngine(abc.ABC):
"""Base protocol class for all LLM engines."""
@abc.abstractmethod
def __init__(self, llm_config: LLMConfig):
"""Initialize the engine with the llm config"""
pass
@abc.abstractmethod
async def start(self):
"""Start the engine"""
pass
@abc.abstractmethod
def routing_stats(self) -> dict:
"""Replica routing stats surfaced to Serve's request router via
``record_routing_stats`` (e.g. the KV-events endpoint for KV-aware
routing)."""
pass
@abc.abstractmethod
async def resolve_lora(self, lora_model: DiskMultiplexConfig):
"""Mounts the LoRA model on the engine, given the local disk path."""
pass
@abc.abstractmethod
async def chat(
self,
request: "ChatCompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, "ChatCompletionResponse", "ErrorResponse"], None]:
"""Run a ChatCompletion with the engine.
To implement this method, you need to take a openAI compatible chat request, internally cast it to the target engine request type, and then call the engine's chat method.
This method is an async generator, so it yields chunks of response and when it is done, it returns None. We have the following convention:
- In case of streaming, yield a string representing data: <json_str>\n\n for each chunk. This should be already openAI compatible, so the higher level can just yield it to the client.
- In case of non-streaming, yield a single object of type ChatCompletionResponse.
- In case of error, yield a single object of type ErrorResponse.
Args:
request: The chat completion request.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Yields:
Union[str, ChatCompletionResponse, ErrorResponse]: A string representing a chunk of the response, a ChatCompletionResponse object, or an ErrorResponse object.
Returns:
None when the generator is done.
"""
pass
@abc.abstractmethod
async def completions(
self,
request: "CompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, "CompletionResponse", "ErrorResponse"], None]:
"""Run a Completion with the engine.
Similar to chat, this method is an async generator, so it yields chunks
of response and when it is done, it returns None. We have the following
convention:
* In case of streaming, yield a string representing data:
<json_str>\n\n for each chunk. This should be already openAI compatible
with completion response format, so the higher level can just yield it
directly to the client.
* In case of non-streaming, yield a single object of type
CompletionResponse.
* In case of error, yield a single object of type ErrorResponse.
Args:
request: The completion request.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Yields:
Union[str, CompletionResponse, ErrorResponse]: A string
representing a chunk of the response, a CompletionResponse object,
or an ErrorResponse object.
Returns:
None when the generator is done.
"""
pass
@abc.abstractmethod
async def embeddings(
self,
request: "EmbeddingRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["EmbeddingResponse", "ErrorResponse"], None]:
"""Run an Embedding with the engine.
This method is different from chat and completion in that it does not
have streaming, but still it is an async generator that yields response
objects and when it is done, it returns None. We have the following
convention:
* yield a single object of type EmbeddingResponse.
* For errors, yield a single object of type ErrorResponse.
Args:
request: The embedding request.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An async generator that yields EmbeddingResponse objects or ErrorResponse objects, and returns None when the generator is done.
"""
pass
@abc.abstractmethod
async def transcriptions(
self,
request: "TranscriptionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, "TranscriptionResponse", "ErrorResponse"], None]:
"""Run a Transcription with the engine.
Similar to chat and completion, this method is an async generator,
so it yields chunks of response and when it is done, it returns None.
We have the following convention:
* In case of streaming, yield a string representing data:
<json_str>\n\n for each chunk. This should be already openAI compatible,
so the higher level can just yield it to the client.
* In case of non-streaming, yield a single object of type TranscriptionResponse.
* In case of error, yield a single object of type ErrorResponse.
Args:
request: The transcription request.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Yields:
Union[str, TranscriptionResponse, ErrorResponse]: A string
representing a chunk of the response, a TranscriptionResponse object,
or an ErrorResponse object.
Returns:
None when the generator is done.
"""
pass
async def tokenize(
self,
request: "TokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["TokenizeResponse", "ErrorResponse"], None]:
"""Tokenize the input text.
This method tokenizes the input prompt or chat messages and returns
the token IDs and optionally token strings.
Args:
request: The tokenize request containing the text to tokenize.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Yields:
Union[TokenizeResponse, ErrorResponse]: A TokenizeResponse object
containing the tokens, or an ErrorResponse object.
Returns:
None when the generator is done.
"""
yield # type: ignore
async def detokenize(
self,
request: "DetokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["DetokenizeResponse", "ErrorResponse"], None]:
"""Detokenize the input token IDs.
This method converts token IDs back into text.
Args:
request: The detokenize request containing the token IDs.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Yields:
Union[DetokenizeResponse, ErrorResponse]: A DetokenizeResponse object
containing the text, or an ErrorResponse object.
Returns:
None when the generator is done.
"""
yield # type: ignore
async def check_health(self) -> None:
"""Check the health of the engine.
Does not return anything. Raise error when the engine is dead and needs
to be restarted.
"""
return
async def build_asgi_app(self) -> Any:
"""Build an ASGI app that serves directly from this engine's frontend.
Used by direct streaming, which serves traffic from the LLMServer
replica's own ASGI ingress instead of the OpenAiIngress deployment.
Engines that do not support direct serving should keep the default,
which raises NotImplementedError.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support direct ASGI serving."
)
##############################################################
# Optional methods
# These methods will be implemented in the future to allow
# more granular life-cycle management of the engine.
# e.g. in usecases like RL training, we need to put the engine
# to sleep during training and wake up during rollouts.
##############################################################
@abc.abstractmethod
async def reset_prefix_cache(self) -> None:
"""Reset the prefix cache of the underlying engine"""
async def sleep(self, **kwargs: Any) -> None:
"""Put the engine to sleep.
The caller should guarantee that no requests are being processed
during the sleep period, before `wakeup` is called.
Args:
**kwargs: Engine-specific sleep options. See the concrete engine
implementation for available options.
"""
pass
async def wakeup(self, **kwargs: Any) -> None:
"""Wake up the engine from sleep mode.
Args:
**kwargs: Engine-specific wakeup options. See the concrete engine
implementation for available options.
"""
pass
async def is_sleeping(self) -> bool:
"""Check whether the engine is currently sleeping.
Returns:
True if the engine is sleeping, False otherwise.
"""
return False
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 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.
"""
raise NotImplementedError("collective_rpc is not implemented for this engine")
async def pause(self, **kwargs: Any) -> None:
"""Pause the engine.
Args:
**kwargs: Engine-specific pause options. Passed through to the engine.
"""
pass
async def resume(self, **kwargs: Any) -> None:
"""Resume the engine.
Args:
**kwargs: Engine-specific resume options. Passed through to the engine.
"""
pass
async def is_paused(self) -> bool:
"""Check whether the engine is currently paused.
Returns:
True if the engine is paused, False otherwise.
"""
return False
def shutdown(self) -> None:
"""Shuts down the engine"""
pass
@@ -0,0 +1,281 @@
import os
import pprint
from typing import Any, Dict, List, Optional, Type, Union
from pydantic import Field, field_validator, model_validator
from ray import serve
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.dict_utils import (
maybe_apply_llm_deployment_config_defaults,
)
from ray.llm._internal.common.utils.import_utils import load_class
from ray.llm._internal.serve.constants import RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.ingress import (
OpenAiIngress,
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.server.builder import (
build_llm_deployment,
)
from ray.llm._internal.serve.core.server.llm_server import LLMServer
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.serve.config import RequestRouterConfig
from ray.serve.deployment import Application
from ray.serve.experimental.round_robin_router import RoundRobinRouter
logger = get_logger(__name__)
def _get_direct_streaming_serve_options(
llm_config: LLMConfig,
override_serve_options: Optional[dict] = None,
) -> dict:
override_serve_options = dict(override_serve_options or {})
if (
"request_router_config" not in llm_config.deployment_config
and "request_router_config" not in override_serve_options
):
override_serve_options["request_router_config"] = RequestRouterConfig(
request_router_class=RoundRobinRouter,
)
return override_serve_options
def _build_direct_streaming_llm_deployment(
llm_config: LLMConfig,
*,
name_prefix: Optional[str] = None,
bind_kwargs: Optional[dict] = None,
override_serve_options: Optional[dict] = None,
deployment_cls: Optional[Type[LLMServer]] = None,
) -> Application:
"""Build an LLM deployment with late-bound ASGI ingress enabled.
Used by the OpenAI, DP, and PD builders to wrap their respective server
class (``LLMServer``, ``DPServer``, ``PDDecodeServer``/``DPPDDecodeServer``)
as the ingress. The real ASGI app (vLLM FastAPI) is constructed inside
``LLMServer.__serve_build_asgi_app__`` after the engine starts; subclasses
inherit this hook.
Replica selection is driven by the deployment's ``request_router_config``.
Default to ``RoundRobinRouter`` when the user hasn't set one, and otherwise
leave their configured value untouched.
"""
server_cls = deployment_cls or llm_config.server_cls or LLMServer
return build_llm_deployment(
llm_config,
name_prefix=name_prefix,
bind_kwargs=bind_kwargs,
deployment_cls=serve.ingress()(server_cls),
override_serve_options=_get_direct_streaming_serve_options(
llm_config, override_serve_options
),
)
def _build_openai_ingress_request_router(
*, server: Application, llm_config: LLMConfig
) -> Application:
"""Build the ingress request router peer for OpenAI compatible LLM apps.
The returned Application is attached to the ingress application with
``Application._with_ingress_request_router``.
``num_replicas`` is pinned to 1 because HAProxy's ingress request router
backend currently expects a single endpoint. TODO(eicherseiji): expose
these as a user-overridable IngressRequestRouterConfig once HAProxy
supports multiple router replicas.
Pre-routing tokenization is wired on only when ``llm_config`` configures a
KVAwareRouter, the sole policy that scores replicas on prompt token IDs.
"""
from ray.llm._internal.serve.core.ingress.router import LLMRouter
deployment = serve.deployment(
LLMRouter,
num_replicas=1,
max_ongoing_requests=1000,
)
return deployment.bind(
server=server,
pre_routing_tokenization=is_kv_aware(llm_config),
)
class IngressClsConfig(BaseModelExtended):
ingress_cls: Union[str, Type[OpenAiIngress]] = Field(
default=OpenAiIngress,
description="The class name of the ingress to use. It can be in form of `module_name.class_name` or `module_name:class_name` or the class itself. The class constructor should take the following arguments: `(llm_deployments: Dict[str, DeploymentHandle], model_cards: Dict[str, ModelCard], lora_paths: Optional[Dict[str, str]] = None, **extra_kwargs)` where the dicts are keyed by base model ID.",
)
ingress_extra_kwargs: Optional[dict] = Field(
default_factory=dict,
description="""The kwargs to bind to the ingress deployment. This will be passed to the ingress class constructor.""",
)
@field_validator("ingress_cls")
@classmethod
def validate_class(
cls, value: Union[str, Type[OpenAiIngress]]
) -> Type[OpenAiIngress]:
if isinstance(value, str):
return load_class(value)
return value
class LLMServingArgs(BaseModelExtended):
llm_configs: List[Union[str, dict, LLMConfig]] = Field(
description="A list of LLMConfigs, or dicts representing LLMConfigs, or paths to yaml files defining LLMConfigs.",
)
ingress_cls_config: Union[dict, IngressClsConfig] = Field(
default_factory=IngressClsConfig,
description="The configuration for the ingress class. It can be a dict representing the ingress class configuration, or an IngressClsConfig object.",
)
ingress_deployment_config: Dict[str, Any] = Field(
default_factory=dict,
description="""
The Ray @server.deployment options for the ingress server.
""",
)
@field_validator("ingress_cls_config")
@classmethod
def _validate_ingress_cls_config(
cls, value: Union[dict, IngressClsConfig]
) -> IngressClsConfig:
if isinstance(value, dict):
return IngressClsConfig.model_validate(value)
return value
@field_validator("llm_configs")
@classmethod
def _validate_llm_configs(
cls, value: List[Union[str, dict, LLMConfig]]
) -> List[LLMConfig]:
llm_configs = []
for config in value:
if isinstance(config, str):
if not os.path.exists(config):
raise ValueError(
f"Could not load model config from {config}, as the file does not exist."
)
llm_configs.append(LLMConfig.from_file(config))
elif isinstance(config, dict):
llm_configs.append(LLMConfig.model_validate(config))
elif isinstance(config, LLMConfig):
llm_configs.append(config)
else:
raise TypeError(f"Invalid LLMConfig type: {type(config)}")
return llm_configs
@model_validator(mode="after")
def _validate_model_ids(self):
"""Validate that model IDs are unique and at least one model is configured."""
if len({m.model_id for m in self.llm_configs}) != len(self.llm_configs):
raise ValueError("Duplicate models found. Make sure model ids are unique.")
if len(self.llm_configs) == 0:
raise ValueError(
"List of models is empty. Maybe some parameters cannot be parsed into the LLMConfig config."
)
return self
def _validate_direct_streaming_ingress_config(
ingress_deployment_config: Optional[dict],
ingress_cls_config: IngressClsConfig,
) -> None:
if ingress_deployment_config:
raise ValueError(
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING does not support "
"ingress_deployment_config because the LLM server class is used "
"directly as the ingress deployment. Configure the server through "
"each LLMConfig.deployment_config instead."
)
if (
ingress_cls_config.ingress_cls != OpenAiIngress
or ingress_cls_config.ingress_extra_kwargs
):
raise ValueError(
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING does not support "
"ingress_cls_config because the LLM server class is used directly "
"as the ingress deployment."
)
def build_openai_app(builder_config: dict) -> Application:
"""Build an OpenAI compatible app with the llm deployment setup from
the given builder configuration.
Args:
builder_config: The configuration for the builder. It has to conform
to the LLMServingArgs pydantic model.
Returns:
The configured Ray Serve Application router.
"""
builder_config = LLMServingArgs.model_validate(builder_config)
llm_configs = builder_config.llm_configs
# Direct streaming attaches LLMRouter as the ingress request router and
# uses the LLMServer deployment itself as the ingress app, so it returns
# before the regular OpenAiIngress wiring.
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
if len(llm_configs) > 1:
raise ValueError(
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING currently supports exactly "
"one LLM config. Multi-model direct streaming requires composing "
"multiple LLMServer deployments into the main application graph, "
"which is not supported yet."
)
_validate_direct_streaming_ingress_config(
builder_config.ingress_deployment_config,
builder_config.ingress_cls_config,
)
direct_deployment = _build_direct_streaming_llm_deployment(llm_configs[0])
logger.info(
"Direct streaming enabled: "
"LLMServer=ingress, LLMRouter=ingress_request_router"
)
return direct_deployment._with_ingress_request_router(
_build_openai_ingress_request_router(
server=direct_deployment, llm_config=llm_configs[0]
)
)
llm_deployments = {c.model_id: build_llm_deployment(c) for c in llm_configs}
model_cards = {c.model_id: to_model_metadata(c.model_id, c) for c in llm_configs}
lora_paths = {
c.model_id: c.lora_config.dynamic_lora_loading_path
for c in llm_configs
if c.lora_config is not None
}
ingress_cls_config = builder_config.ingress_cls_config
default_ingress_options = ingress_cls_config.ingress_cls.get_deployment_options(
llm_configs
)
ingress_options = maybe_apply_llm_deployment_config_defaults(
default_ingress_options, builder_config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(ingress_cls_config.ingress_cls)
logger.info("============== Ingress Options ==============")
logger.info(pprint.pformat(ingress_options))
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments=llm_deployments,
model_cards=model_cards,
lora_paths=lora_paths,
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,138 @@
"""Development/RL-focused ingress with control plane endpoints.
This module provides DevIngress, an extension of OpenAiIngress that adds
control plane endpoints for managing engine lifecycle. These endpoints
are useful for RL training workflows where engines need to be put to sleep
during training and woken up for inference.
Endpoints:
POST /sleep: Put engine to sleep (frees GPU memory)
POST /wakeup: Wake up engine from sleep
GET /is_sleeping: Check if engine is sleeping
POST /pause: Pause generation (keeps weights in GPU)
POST /resume: Resume generation after pause
GET /is_paused: Check if engine is paused
POST /reset_prefix_cache: Reset the KV prefix cache
POST /collective_rpc: Execute collective RPC on all workers
"""
import pprint
from typing import Dict
from ray import serve
from ray.llm._internal.common.dict_utils import (
maybe_apply_llm_deployment_config_defaults,
)
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.builder import LLMServingArgs
from ray.llm._internal.serve.core.ingress.ingress import (
DEFAULT_ENDPOINTS,
OpenAiIngress,
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.ingress.mixins import (
CacheManagerIngressMixin,
CollectiveRpcIngressMixin,
PausableIngressMixin,
SleepableIngressMixin,
)
from ray.llm._internal.serve.core.server.builder import build_llm_deployment
from ray.llm._internal.serve.observability.logging import get_logger
from ray.serve.deployment import Application
logger = get_logger(__name__)
# Endpoint map for DevIngress - includes all default endpoints plus control plane
DEV_ENDPOINTS = {
**CacheManagerIngressMixin.ENDPOINTS,
**CollectiveRpcIngressMixin.ENDPOINTS,
**PausableIngressMixin.ENDPOINTS,
**SleepableIngressMixin.ENDPOINTS,
**DEFAULT_ENDPOINTS,
}
class DevIngress(
OpenAiIngress,
SleepableIngressMixin,
PausableIngressMixin,
CacheManagerIngressMixin,
CollectiveRpcIngressMixin,
):
"""OpenAI-compatible ingress with additional control plane endpoints.
This ingress extends the standard OpenAI endpoints with control plane
operations for managing engine lifecycle. These are useful for:
- RL training: Put engines to sleep during training, wake up for rollouts
- Memory management: Free GPU memory between inference workloads
- Benchmarking: Reset prefix cache between benchmark rounds
- RLHF: Execute collective RPC on all workers for weight updates
Control plane endpoints provided by mixins:
- SleepableIngressMixin: /sleep, /wakeup, /is_sleeping
- PausableIngressMixin: /pause, /resume, /is_paused
- CacheManagerIngressMixin: /reset_prefix_cache
- CollectiveRpcIngressMixin: /collective_rpc
WARNING: These endpoints are intended for development and trusted
environments. Consider access control in production deployments.
"""
pass
def build_dev_openai_app(builder_config: Dict) -> Application:
"""Build an OpenAI compatible app with dev/control plane endpoints.
This is similar to build_openai_app but uses DevIngress with
additional control plane endpoints:
- /sleep, /wakeup, /is_sleeping (sleep mode - offloads weights to CPU)
- /pause, /resume, /is_paused (pause mode - keeps weights in GPU)
- /reset_prefix_cache (cache management)
- /collective_rpc (RLHF - execute RPC on all workers)
Args:
builder_config: Configuration conforming to LLMServingArgs.
See LLMServingArgs for details on the expected structure.
Returns:
The configured Ray Serve Application.
Example:
config = {
"llm_configs": [llm_config],
"ingress_deployment_config": {}
}
app = build_dev_openai_app(config)
serve.run(app)
"""
config = LLMServingArgs.model_validate(builder_config)
llm_configs = config.llm_configs
llm_deployments = {c.model_id: build_llm_deployment(c) for c in llm_configs}
model_cards = {c.model_id: to_model_metadata(c.model_id, c) for c in llm_configs}
lora_paths = {
c.model_id: c.lora_config.dynamic_lora_loading_path
for c in llm_configs
if c.lora_config is not None
}
ingress_cls_config = config.ingress_cls_config
default_ingress_options = DevIngress.get_deployment_options(llm_configs)
ingress_options = maybe_apply_llm_deployment_config_defaults(
default_ingress_options, config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(DevIngress, endpoint_map=DEV_ENDPOINTS)
logger.info("============== Ingress Options ==============")
logger.info(pprint.pformat(ingress_options))
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments=llm_deployments,
model_cards=model_cards,
lora_paths=lora_paths,
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,694 @@
import asyncio
import copy
import sys
from contextlib import asynccontextmanager
from enum import Enum
from typing import (
Annotated,
Any,
AsyncGenerator,
Awaitable,
Callable,
Dict,
List,
Optional,
Type,
Union,
)
from fastapi import FastAPI, Form, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse, Response, StreamingResponse
from ray import serve
from ray.llm._internal.common.utils.lora_utils import (
get_base_model_id,
get_lora_model_ids,
)
from ray.llm._internal.serve.constants import (
DEFAULT_LLM_ROUTER_HTTP_TIMEOUT,
DEFAULT_MAX_ONGOING_REQUESTS,
DEFAULT_MAX_TARGET_ONGOING_REQUESTS,
)
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
CompletionRequest,
DetokenizeRequest,
DetokenizeResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorResponse,
LLMChatResponse,
LLMCompletionsResponse,
LLMEmbeddingsResponse,
LLMScoreResponse,
LLMTranscriptionResponse,
ModelCard,
ModelList,
OpenAIHTTPException,
ScoreRequest,
ScoreResponse,
TokenizeCompletionRequest,
TokenizeResponse,
TranscriptionRequest,
)
from ray.llm._internal.serve.core.ingress.middleware import (
SetRequestIdMiddleware,
add_exception_handling_middleware,
)
from ray.llm._internal.serve.core.ingress.utils import (
NON_STREAMING_RESPONSE_TYPES,
_openai_json_wrapper,
_peek_at_generator,
_sanitize_chat_completion_request,
)
from ray.llm._internal.serve.core.protocol import DeploymentProtocol, RawRequestInfo
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.observability.metrics.fast_api_metrics import (
add_http_metrics_middleware,
metrics_lifespan,
)
from ray.llm._internal.serve.utils.lora_serve_utils import (
get_lora_model_metadata,
)
from ray.llm._internal.serve.utils.server_utils import replace_prefix
from ray.serve._private.http_util import session_id_from_headers
from ray.serve.handle import DeploymentHandle
# Import asyncio timeout depends on python version
if sys.version_info >= (3, 11):
from asyncio import timeout
else:
from async_timeout import timeout
logger = get_logger(__name__)
DEFAULT_INGRESS_OPTIONS = {
"max_ongoing_requests": DEFAULT_MAX_ONGOING_REQUESTS,
"autoscaling_config": {
"target_ongoing_requests": DEFAULT_MAX_TARGET_ONGOING_REQUESTS,
},
}
def _get_min_replicas_from_llm_config(config: LLMConfig) -> Optional[int]:
autoscaling_config = config.deployment_config.get("autoscaling_config")
if autoscaling_config is None:
return None
if isinstance(autoscaling_config, dict):
return autoscaling_config.get("min_replicas")
return getattr(autoscaling_config, "min_replicas", None)
def _all_models_scale_to_zero(llm_configs: Optional[List[LLMConfig]]) -> bool:
"""Check if all models are configured with min_replicas == 0."""
if not llm_configs:
return False
return all(_get_min_replicas_from_llm_config(config) == 0 for config in llm_configs)
# These methods correspond to functions defined in the LLMEngine class in python/ray/llm/_internal/serve/deployments/llm/llm_engine.py
class CallMethod(Enum):
CHAT = "chat"
COMPLETIONS = "completions"
TRANSCRIPTIONS = "transcriptions"
DEFAULT_ENDPOINTS = {
"models": lambda app: app.get("/v1/models", response_model=ModelList),
"model_data": lambda app: app.get(
"/v1/models/{model:path}", response_model=ModelCard
),
"completions": lambda app: app.post("/v1/completions"),
"chat": lambda app: app.post("/v1/chat/completions"),
"embeddings": lambda app: app.post("/v1/embeddings"),
"transcriptions": lambda app: app.post(
"/v1/audio/transcriptions",
),
"score": lambda app: app.post("/v1/score"),
"tokenize": lambda app: app.post("/tokenize"),
"detokenize": lambda app: app.post("/detokenize"),
}
def init() -> FastAPI:
_fastapi_router_app = FastAPI(lifespan=metrics_lifespan)
# NOTE: PLEASE READ CAREFULLY BEFORE MODIFYING
#
# FastAPI middleware is executed in LIFO (last-in, first-out) order,
# hence maintaining current ordering is crucial as some of the middleware
# might have data dependency on the other: for ex, telemetry middleware
# depends on middleware generating request-id
#
# Add exception handling middleware
# NOTE: This middleware should be added first such that it's intercepting
# exceptions from the handlers, avoiding them propagating to other
# middleware (for ex, telemetry)
add_exception_handling_middleware(_fastapi_router_app)
# Configure CORS middleware
_fastapi_router_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add HTTP metrics middleware
add_http_metrics_middleware(_fastapi_router_app)
# Inject unique per-request ID
#
# NOTE: This middleware should be executed among the last (since
# middleware is executed in LIFO).
_fastapi_router_app.add_middleware(SetRequestIdMiddleware)
return _fastapi_router_app
def make_fastapi_ingress(
cls: Type,
*,
endpoint_map: Optional[Dict[str, Callable[[FastAPI], Callable]]] = None,
app: Optional[FastAPI] = None,
):
"""
Create a Ray Serve ingress deployment from a class and endpoint mapping.
Args:
cls: The class to convert into an ingress deployment
endpoint_map: Dictionary mapping method names to FastAPI route
decorators. Each value is a lambda that takes a FastAPI app and
returns a route decorator.
app: Optional FastAPI app to use for the ingress deployment. If not
provided, a new FastAPI app will be created.
Returns:
A class decorated with @serve.ingress
Example:
endpoint_map = {
"increment": lambda app: app.post("/increment"),
"get_counter": lambda app: app.get("/counter"),
}
# With additional FastAPI parameters:
endpoint_map = {
"increment": lambda app: app.post("/increment", status_code=201, tags=["counter"]),
"get_counter": lambda app: app.get("/counter", response_model=CounterResponse),
}
"""
if app is None:
app = init()
if endpoint_map is None:
endpoint_map = DEFAULT_ENDPOINTS
# Create a new class that inherits from the original to avoid modifying it
# in-place. We populate the new class's __dict__ with decorated methods.
class_dict = {}
# Apply route decorators to the class methods and store them in class_dict
for method_name, route_factory in endpoint_map.items():
# Get the route decorator from the lambda
route_decorator = route_factory(app)
# Get the original method from the class
original_method = getattr(cls, method_name)
# Apply the decorator to the original method
decorated_method = route_decorator(original_method)
# Store in the class dict so it will be properly bound to new_cls
class_dict[method_name] = decorated_method
# Create new class with the decorated methods in its __dict__.
# We keep the same __name__ and __qualname__ as the original class
# so that the new class properly represents the input class.
new_cls = type(cls.__name__, (cls,), class_dict)
new_cls.__qualname__ = cls.__qualname__
# Apply the serve.ingress decorator to the new class
return serve.ingress(app)(new_cls)
@asynccontextmanager
async def router_request_timeout(timeout_duration: float):
try:
async with timeout(timeout_duration):
yield
except asyncio.TimeoutError as e:
raise OpenAIHTTPException(
status_code=status.HTTP_408_REQUEST_TIMEOUT,
message="Request server side timeout",
internal_message=str(e),
)
class OpenAiIngress(DeploymentProtocol):
def __init__(
self,
llm_deployments: Dict[str, DeploymentHandle],
model_cards: Dict[str, ModelCard],
*,
lora_paths: Optional[Dict[str, str]] = None,
_get_lora_model_metadata_func: Optional[
Callable[[str, str], Awaitable[Dict[str, Any]]]
] = None,
):
if set(llm_deployments) != set(model_cards):
raise ValueError(
"llm_deployments and model_cards must have the same model IDs. "
f"Got llm_deployments={sorted(llm_deployments)}, "
f"model_cards={sorted(model_cards)}."
)
self._default_serve_handles: Dict[str, DeploymentHandle] = dict(llm_deployments)
self._model_cards: Dict[str, ModelCard] = dict(model_cards)
self._lora_paths: Dict[str, str] = dict(lora_paths or {})
# Configuring a ServeHandle with .options() creates a new ServeHandle
# object, which contains a new metrics pusher and long-polling call.
# Creating too many ServeHandles can impact event-loop and Serve Controller
# performance, so we save configured ServeHandles here and reuse them.
self._configured_serve_handles: Dict[str, DeploymentHandle] = {}
self._get_lora_model_metadata_func = (
_get_lora_model_metadata_func or self._default_get_lora_model_metadata_func
)
async def _default_get_lora_model_metadata_func(
self, model_id: str, base_path: str
) -> Dict[str, Any]:
return await get_lora_model_metadata(model_id, base_path)
async def check_health(self):
pass
def _get_configured_serve_handle(self, model_id: str):
"""Gets a ServeHandle to a model deployment.
Configures the handle's options, and stores it in a cache.
If the model_id includes LoRA suffix, we set the model ID as
the multiplexed_model_id, so the request uses Serve's multiplexed
routing logic.
If the model_id is a base model- even if the model has LoRA
adapters- we don't set multiplexed_model_id. Setting
multiplexed_model_id would cause base model requests to be
sent to a single model replica, instead of being load
balanced across all replicas. This is undesirable for base
model requests (unlike LoRA requests) because all the replicas
have a copy of the base model.
"""
if model_id not in self._configured_serve_handles:
base_model_id = get_base_model_id(model_id)
if base_model_id in self._default_serve_handles:
if model_id == base_model_id:
default_handle = self._default_serve_handles[model_id]
configured_handle = default_handle.options(stream=True)
self._configured_serve_handles[model_id] = configured_handle
else:
default_handle = self._default_serve_handles[base_model_id]
configured_handle = default_handle.options(
stream=True,
multiplexed_model_id=model_id,
)
self._configured_serve_handles[model_id] = configured_handle
else:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f'Could not find model with id "{model_id}".',
)
return self._configured_serve_handles[model_id]
async def _get_model_id(self, model: Optional[str]) -> str:
# Default to the only configured model if no model specified
if model is None:
if len(self._model_cards) == 1:
model = next(iter(self._model_cards.keys()))
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Model parameter is required when multiple models are configured. "
f"Available models: {list(self._model_cards.keys())}",
)
base_model_id = get_base_model_id(model)
if base_model_id not in self._model_cards:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f'Got request for model "{model}". '
f'Could not find base model with ID "{base_model_id}".',
)
# Return original model ID so multiplexed routing works correctly.
return model
async def _get_response(
self,
*,
body: Union[
CompletionRequest,
ChatCompletionRequest,
EmbeddingRequest,
TranscriptionRequest,
ScoreRequest,
],
call_method: str,
raw_request: Optional[Request] = None,
) -> AsyncGenerator[
Union[
LLMChatResponse,
LLMCompletionsResponse,
LLMEmbeddingsResponse,
LLMTranscriptionResponse,
LLMScoreResponse,
],
None,
]:
"""Calls the model deployment and returns the stream."""
model_id = await self._get_model_id(body.model)
model_handle = self._get_configured_serve_handle(model_id)
# Propagate the session id from the client request to the downstream
# LLMServer handle. The Serve HTTP proxy attaches session_id to the
# *ingress* deployment handle (proxy.py:_setup_request_context), but
# that does NOT carry over to a second handle hop (here -> LLMServer).
# Re-read the configured session header from the raw request and apply
# it via .options(session_id=...) so session-aware request routers
# (e.g. ConsistentHashRouter) on the LLMServer deployment see it.
# Uses the same case-insensitive, separator-tolerant matcher as
# proxy.py so a `-`/`_` rewrite by an intermediate proxy doesn't
# silently drop session affinity on this second hop.
if raw_request is not None:
session_id = session_id_from_headers(raw_request.headers)
if session_id:
model_handle = model_handle.options(session_id=session_id)
# TODO(seiji): Remove when we update to Pydantic v2.11+ with the fix
# for tool calling ValidatorIterator serialization issue.
if isinstance(body, ChatCompletionRequest):
body = _sanitize_chat_completion_request(body)
# Convert Starlette request to serializable RawRequestInfo
raw_request_info: Optional[RawRequestInfo] = None
if raw_request is not None:
raw_request_info = RawRequestInfo.from_starlette_request(raw_request)
async for response in getattr(model_handle, call_method).remote(
body, raw_request_info
):
yield response
async def model(self, model_id: str) -> Optional[ModelCard]:
if model_id in self._model_cards:
return self._model_cards[model_id]
base_model_id = get_base_model_id(model_id)
base_path = self._lora_paths.get(base_model_id)
if base_path is not None:
try:
overrides = await self._get_lora_model_metadata_func(
model_id, base_path
)
base_card = self._model_cards[base_model_id]
return ModelCard(
id=model_id,
object="model",
owned_by=base_card.owned_by,
permission=list(base_card.permission),
metadata={**base_card.metadata, **overrides},
)
except HTTPException:
logger.exception(
"Unable to retrieve LoRA adapter config file for "
f'"{model_id}". Omitting it from list of available models. '
"Check that adapter config file exists in cloud bucket."
)
async def models(self) -> ModelList:
"""OpenAI API-compliant endpoint to get all rayllm models."""
all_models = dict()
for base_model_id in self._model_cards:
# Add the base model.
all_models[base_model_id] = await self.model(base_model_id)
base_path = self._lora_paths.get(base_model_id)
if base_path is not None:
# Add all the fine-tuned models.
lora_model_ids = get_lora_model_ids(
dynamic_lora_loading_path=base_path,
base_model_id=base_model_id,
)
for lora_id in lora_model_ids:
model_data = await self.model(lora_id)
if model_data is not None:
all_models[lora_id] = model_data
return ModelList(data=list(all_models.values()))
async def model_data(self, model: str) -> ModelCard:
"""OpenAI API-compliant endpoint to get one rayllm model.
Args:
model: The model ID (e.g. "amazon/LightGPT").
Returns:
The ``ModelCard`` for ``model``.
"""
model = replace_prefix(model)
model_data = await self.model(model)
if model_data is None:
raise OpenAIHTTPException(
message=f"Unable to find {model}. Please ensure that the model exists and you have permission.",
status_code=status.HTTP_404_NOT_FOUND,
type="InvalidModel",
)
return model_data
async def _process_llm_request(
self,
body: Union[CompletionRequest, ChatCompletionRequest, TranscriptionRequest],
call_method: str,
raw_request: Optional[Request] = None,
) -> Response:
async with router_request_timeout(DEFAULT_LLM_ROUTER_HTTP_TIMEOUT):
gen = self._get_response(
body=body, call_method=call_method, raw_request=raw_request
)
# In streaming with batching enabled, this first response can be a list of chunks.
initial_response, gen = await _peek_at_generator(gen)
if isinstance(initial_response, list):
first_chunk = initial_response[0]
else:
first_chunk = initial_response
if isinstance(first_chunk, ErrorResponse):
raise OpenAIHTTPException(
message=first_chunk.error.message,
status_code=first_chunk.error.code,
type=first_chunk.error.type,
)
if isinstance(first_chunk, NON_STREAMING_RESPONSE_TYPES):
# Not streaming, first chunk should be a single response
return JSONResponse(content=first_chunk.model_dump())
# In case of streaming we need to iterate over the chunks and yield them
openai_stream_generator = _openai_json_wrapper(gen)
return StreamingResponse(
openai_stream_generator, media_type="text/event-stream"
)
async def completions(self, body: CompletionRequest, request: Request) -> Response:
"""Given a prompt, the model will return one or more predicted completions,
and can also return the probabilities of alternative tokens at each position.
Args:
body: The completion request.
request: The raw FastAPI request object.
Returns:
A response object with completions.
"""
return await self._process_llm_request(
body, call_method=CallMethod.COMPLETIONS.value, raw_request=request
)
async def chat(self, body: ChatCompletionRequest, request: Request) -> Response:
"""Given a prompt, the model will return one or more predicted completions,
and can also return the probabilities of alternative tokens at each position.
Args:
body: The chat completion request.
request: The raw FastAPI request object.
Returns:
A response object with completions.
"""
return await self._process_llm_request(
body, call_method=CallMethod.CHAT.value, raw_request=request
)
async def embeddings(self, body: EmbeddingRequest, request: Request) -> Response:
"""Create embeddings for the provided input.
Args:
body: The embedding request.
request: The raw FastAPI request object.
Returns:
A response object with embeddings.
"""
async with router_request_timeout(DEFAULT_LLM_ROUTER_HTTP_TIMEOUT):
results = self._get_response(
body=body, call_method="embeddings", raw_request=request
)
result = await results.__anext__()
if isinstance(result, ErrorResponse):
raise OpenAIHTTPException(
message=result.error.message,
status_code=result.error.code,
type=result.error.type,
)
if isinstance(result, EmbeddingResponse):
return JSONResponse(content=result.model_dump())
# Annotated[..., Form()] is wrapper that is used to handle multiple form data, which is how audio is sent in transcription requests.
# vLLM implementation for handling transcription requests: https://github.com/vllm-project/vllm/blob/0825197bee8dea547f2ab25f48afd8aea0cd2578/vllm/entrypoints/openai/api_server.py#L839.
async def transcriptions(
self, body: Annotated[TranscriptionRequest, Form()], request: Request
) -> Response:
"""Create transcription for the provided audio input.
Args:
body: The TranscriptionRequest object.
request: The raw FastAPI request object.
Returns:
A response object with transcriptions.
"""
return await self._process_llm_request(
body, call_method=CallMethod.TRANSCRIPTIONS.value, raw_request=request
)
async def score(self, body: ScoreRequest, request: Request) -> Response:
"""Create scores for the provided text pairs.
Note: This is a vLLM specific endpoint.
Args:
body: The score request containing input text pairs to score.
request: The raw FastAPI request object.
Returns:
A response object with scores.
"""
async with router_request_timeout(DEFAULT_LLM_ROUTER_HTTP_TIMEOUT):
results = self._get_response(
body=body, call_method="score", raw_request=request
)
result = await results.__anext__()
if isinstance(result, ErrorResponse):
raise OpenAIHTTPException(
message=result.error.message,
status_code=result.error.code,
type=result.error.type,
)
if isinstance(result, ScoreResponse):
return JSONResponse(content=result.model_dump())
async def tokenize(
self, body: TokenizeCompletionRequest, request: Request
) -> Response:
"""Tokenize text into token IDs.
This endpoint tokenizes the provided text prompt and returns the token IDs,
counts, and optionally token strings.
Note: This is a vLLM specific endpoint.
Args:
body: The tokenize request containing the text to tokenize.
request: The raw FastAPI request object.
Returns:
A response object with token IDs and metadata.
"""
async with router_request_timeout(DEFAULT_LLM_ROUTER_HTTP_TIMEOUT):
results = self._get_response(
body=body, call_method="tokenize", raw_request=request
)
result = await results.__anext__()
if isinstance(result, ErrorResponse):
raise OpenAIHTTPException(
message=result.error.message,
status_code=result.error.code,
type=result.error.type,
)
if isinstance(result, TokenizeResponse):
return JSONResponse(content=result.model_dump())
async def detokenize(self, body: DetokenizeRequest, request: Request) -> Response:
"""Convert token IDs back to text.
This endpoint detokenizes the provided token IDs and returns the
corresponding text.
Note: This is a vLLM specific endpoint.
Args:
body: The detokenize request containing the token IDs.
request: The raw FastAPI request object.
Returns:
A response object with the detokenized text.
"""
async with router_request_timeout(DEFAULT_LLM_ROUTER_HTTP_TIMEOUT):
results = self._get_response(
body=body, call_method="detokenize", raw_request=request
)
result = await results.__anext__()
if isinstance(result, ErrorResponse):
raise OpenAIHTTPException(
message=result.error.message,
status_code=result.error.code,
type=result.error.type,
)
if isinstance(result, DetokenizeResponse):
return JSONResponse(content=result.model_dump())
@classmethod
def get_deployment_options(
cls, llm_configs: Optional[List[LLMConfig]] = None
) -> Dict[str, Any]:
"""Get the deployment options for the ingress deployment.
If all models are configured with min_replicas=0 (scale-to-zero),
the ingress will also be configured with min_replicas=0 so that
the worker node/GPU instance can be fully released when idle.
Args:
llm_configs: The LLM configs to infer the number of ingress replicas from.
Returns:
A dictionary containing the deployment options for the ingress deployment.
"""
options = copy.deepcopy(DEFAULT_INGRESS_OPTIONS)
if _all_models_scale_to_zero(llm_configs):
options.setdefault("autoscaling_config", {})["min_replicas"] = 0
return options
@@ -0,0 +1,181 @@
import uuid
from asyncio import CancelledError
from typing import Optional
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse, Response
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.utils.server_utils import (
get_response_for_error,
)
logger = get_logger(__file__)
def get_request_id(request: Request) -> str:
"""Fetches request-id from Starlette's request object.
NOTE: This method relies on "request_id" value to be injected into the
Starlette's ``request.state`` via ``inject_request_id`` middleware.
Args:
request: Starlette request object.
Returns:
Id allowing to identify the particular request, or ``None`` if not set.
"""
return getattr(request.state, "request_id", None)
async def _handle_validation_error(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Handle pydantic validation errors in an OpenAI-like format."""
error_details = exc.errors()[0] if exc.errors() else {"msg": "Invalid request"}
error_msg = error_details.get("msg", "Unknown validation error")
error_loc = error_details.get("loc", ("body"))
error_input = error_details.get("input", None)
msg = f"Invalid request format: {error_msg} at {error_loc}"
error_response = {
"error": {
"message": msg,
"type": error_details.get("type", "invalid_request_error"),
"param": error_input,
"code": "invalid_parameter",
}
}
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=error_response)
def _uncaught_exception_handler(request: Request, e: Exception):
"""This method serves as an uncaught exception handler being
the last resort to return properly formatted response.
NOTE: Exceptions from application handlers should NOT be reaching this point,
this handler is here to intercept "fly-away" exceptions and should not
be handled for handling of converting application exceptions into
appropriate responses
"""
if isinstance(e, CancelledError):
return JSONResponse(content={}, status_code=204)
request_id = get_request_id(request)
logger.error(f"Uncaught exception while handling request {request_id}", exc_info=e)
error_response = get_response_for_error(e, request_id)
return JSONResponse(
content=error_response.model_dump(), status_code=error_response.error.code
)
def add_exception_handling_middleware(router: FastAPI):
# NOTE: PLEASE READ CAREFULLY BEFORE CHANGING
#
# Starlette has different behavior depending on the Exception class being handled
# that we unfortunately have to take into account here:
#
# - Handler for `Exception` will be added as uncaught exception handler (of last resort)
# that is going to be executed absolute last, making sure that in case of any fly-away
# (uncaught) exception
# - Handlers for any other classes of exceptions will be executed as last middleware layer,
# therefore being to intercept any exceptions originating from the handler before it
# propagates to the middleware above it
#
# As such we're aiming for 2 goals here:
# - Intercepting exceptions from the handlers, converting them into proper user-facing
# response (avoiding exception propagation up the middleware stack)
# - Adding uncaught exception handler (of last resort) to intercept any exceptions that
# might be originating from the middleware itself
async def _handle_application_exceptions(
request: Request, call_next: RequestResponseEndpoint
) -> Response:
"""This method intercepts application level exceptions not handled by the
application code converting them into appropriately formatted (JSON) response
"""
try:
return await call_next(request)
except CancelledError as ce:
# NOTE: We re-raise CancelledError as is to let other middleware handle it.
# Since no response is expected in this case, it's deferred to uncaught
# exception handler to ultimately handle it
raise ce
except RequestValidationError as e:
return await _handle_validation_error(request, e)
except Exception as e:
request_id = get_request_id(request)
error_response = get_response_for_error(e, request_id)
return JSONResponse(
content=error_response.model_dump(),
status_code=error_response.error.code,
)
# This adds last-resort uncaught exception handler into Starlette
router.add_exception_handler(Exception, _uncaught_exception_handler)
# Add validation error handler
router.add_exception_handler(RequestValidationError, _handle_validation_error)
# This adds application exception handler, allowing to convert application
# exceptions into properly formatted responses
router.add_middleware(
BaseHTTPMiddleware,
dispatch=_handle_application_exceptions,
)
class SetRequestIdMiddleware:
"""Injects request ID into the request's state.
The ID is either:
1. the value of the request's "x-request-id" header, set by Ray
Serve's Proxy, or
2. if "x-request-id" header is unavailable, this middleware creates
a UUIDv4 request ID.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
headers = list(scope.get("headers", []))
request_id = None
for name, value in headers:
if name.lower() == b"x-request-id" and value:
request_id = value.decode()
break
if request_id is None:
request_id = str(uuid.uuid4())
headers.append((b"x-request-id", request_id.encode()))
scope["headers"] = headers
request = Request(scope)
request.state.request_id = request_id
return await self.app(scope, receive, send)
def get_user_id(request: Request) -> Optional[str]:
"""Fetches user id inside Starlette's request object.
NOTE: This method relies on "user_id" value to be injected into the
Starlette's ``request.state`` via authentication middleware.
Args:
request: Starlette request object.
Returns:
Id identifying the particular user, or ``None`` if not set.
"""
return getattr(request.state, "user_id", None)
@@ -0,0 +1,44 @@
"""Ingress capability mixins.
Provides HTTP endpoint mixins for control plane operations.
"""
from ray.llm._internal.serve.core.ingress.mixins.cache_manager import (
CacheManagerIngressMixin,
ResetPrefixCacheRequest,
)
from ray.llm._internal.serve.core.ingress.mixins.collective_rpc import (
CollectiveRpcIngressMixin,
CollectiveRpcRequest,
CollectiveRpcResponse,
ReplicaResult,
)
from ray.llm._internal.serve.core.ingress.mixins.pausable import (
IsPausedResponse,
PausableIngressMixin,
PauseRequest,
ResumeRequest,
)
from ray.llm._internal.serve.core.ingress.mixins.sleepable import (
IsSleepingResponse,
SleepableIngressMixin,
SleepRequest,
WakeupRequest,
)
__all__ = [
"CacheManagerIngressMixin",
"CollectiveRpcIngressMixin",
"PausableIngressMixin",
"SleepableIngressMixin",
"CollectiveRpcRequest",
"CollectiveRpcResponse",
"ReplicaResult",
"ResetPrefixCacheRequest",
"PauseRequest",
"ResumeRequest",
"IsPausedResponse",
"SleepRequest",
"WakeupRequest",
"IsSleepingResponse",
]
@@ -0,0 +1,26 @@
import asyncio
from typing import Any, List
from ray.llm._internal.serve.utils.broadcast import broadcast
class ReplicaBroadcastable:
async def _broadcast_to_replicas(
self, model: str, method: str, kwargs: dict | None = None
) -> List[Any]:
"""Broadcast a command to all replicas and return their results.
Args:
model: The model ID to broadcast to.
method: The method name to call on each replica.
kwargs: Optional kwargs to pass to the method.
Returns:
List of results from each replica.
"""
model_id = await self._get_model_id(model)
handle = self._get_configured_serve_handle(model_id)
# Run blocking broadcast() in a thread to avoid blocking the event loop.
# broadcast() uses ray.get() internally which is synchronous.
results = await asyncio.to_thread(broadcast, handle, method, kwargs=kwargs)
return results
@@ -0,0 +1,53 @@
"""Cache manager ingress mixin.
Provides HTTP endpoints for cache management control plane operations.
"""
from pydantic import BaseModel
from starlette.responses import Response
from ray.llm._internal.serve.core.ingress.mixins.broadcastable import (
ReplicaBroadcastable,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
# --- Pydantic Models ---
class ResetPrefixCacheRequest(BaseModel):
"""Request to reset the prefix cache."""
model: str
# --- Mixin ---
class CacheManagerIngressMixin(ReplicaBroadcastable):
"""Ingress mixin for /reset_prefix_cache endpoint.
Adds control plane endpoint for managing the KV prefix cache.
"""
ENDPOINTS = {
"reset_prefix_cache": lambda app: app.post("/reset_prefix_cache"),
}
async def reset_prefix_cache(self, body: ResetPrefixCacheRequest) -> Response:
"""Reset the KV prefix cache on all replicas for the specified model.
Clears cached key-value pairs from previous requests. Useful for
benchmarking or when cache invalidation is needed.
Args:
body: Request containing the model ID.
Returns:
200 OK on success.
"""
logger.info("Resetting prefix cache for model: %s", body.model)
await self._broadcast_to_replicas(body.model, "reset_prefix_cache")
return Response(status_code=200)
@@ -0,0 +1,100 @@
"""Collective RPC ingress mixin.
Provides HTTP endpoint for collective RPC operations across all replicas
and their workers, enabling RLHF workflows where a trainer forms a single
NCCL process group with all TP/PP workers across all replicas.
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from ray.llm._internal.serve.core.ingress.mixins.broadcastable import (
ReplicaBroadcastable,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
# --- Pydantic Models ---
class CollectiveRpcRequest(BaseModel):
"""Request to execute a collective RPC on all replicas."""
model: str
method: str
args: List[Any] = Field(default_factory=list)
kwargs: Dict[str, Any] = Field(default_factory=dict)
timeout: Optional[float] = None
class ReplicaResult(BaseModel):
"""Result from a single replica containing all worker results."""
replica: int
worker_results: List[Any]
class CollectiveRpcResponse(BaseModel):
"""Response containing results from all replicas."""
results: List[ReplicaResult]
# --- Mixin ---
class CollectiveRpcIngressMixin(ReplicaBroadcastable):
"""Ingress mixin for /collective_rpc endpoint.
Adds control plane endpoint for executing collective RPC calls across
all replicas and their workers. This is used for RLHF workflows where
a trainer needs to communicate with all TP/PP workers across all replicas.
"""
ENDPOINTS = {
"collective_rpc": lambda app: app.post("/collective_rpc"),
}
async def collective_rpc(self, body: CollectiveRpcRequest) -> CollectiveRpcResponse:
"""Execute a collective RPC on all replicas for the specified model.
This broadcasts the RPC call to all replicas, and each replica
executes the call on all its workers (TP/PP ranks).
Args:
body: Request containing the model ID, method name, args, kwargs,
and optional timeout.
Returns:
CollectiveRpcResponse with results from all replicas.
"""
logger.info(
"Executing collective_rpc '%s' for model %s with args=%s, kwargs=%s",
body.method,
body.model,
body.args,
body.kwargs,
)
# Broadcast to all replicas - each replica returns a list of worker results
replica_results = await self._broadcast_to_replicas(
body.model,
"collective_rpc",
kwargs={
"method": body.method,
"args": tuple(body.args),
"kwargs": body.kwargs,
"timeout": body.timeout,
},
)
# Format results with replica index for debugging
results = [
ReplicaResult(replica=i, worker_results=worker_results or [])
for i, worker_results in enumerate(replica_results or [])
]
return CollectiveRpcResponse(results=results)
@@ -0,0 +1,115 @@
"""Pausable ingress mixin.
Provides HTTP endpoints for pause/resume control plane operations.
"""
from typing import Any, Dict
from fastapi import Query
from pydantic import BaseModel, Field
from starlette.responses import Response
from ray.llm._internal.serve.core.ingress.mixins.broadcastable import (
ReplicaBroadcastable,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
# --- Pydantic Models ---
class PauseRequest(BaseModel):
"""Request to pause generation on an engine."""
model: str
options: Dict[str, Any] = Field(
default_factory=dict,
description="Engine-specific pause options (e.g., mode, clear_cache)",
)
class ResumeRequest(BaseModel):
"""Request to resume generation on an engine."""
model: str
options: Dict[str, Any] = Field(
default_factory=dict,
description="Engine-specific resume options",
)
class IsPausedResponse(BaseModel):
"""Response indicating whether the engine is paused."""
is_paused: bool
# --- Mixin ---
class PausableIngressMixin(ReplicaBroadcastable):
"""Ingress mixin for /pause, /resume, /is_paused endpoints.
Adds control plane endpoints for managing engine pause state.
Pause mode halts generation/encoding while keeping weights in GPU memory.
Unlike sleep mode, pause does not offload weights to CPU.
"""
ENDPOINTS = {
"pause": lambda app: app.post("/pause"),
"resume": lambda app: app.post("/resume"),
"is_paused": lambda app: app.get("/is_paused"),
}
async def pause(self, body: PauseRequest) -> Response:
"""Pause generation on all replicas for the specified model.
This halts generation/encoding requests while keeping model weights
in GPU memory. New requests are blocked until resume is called.
Unlike sleep mode, pause does not offload weights to CPU.
Args:
body: Request containing the model ID and engine-specific options.
Options may include:
- mode (str): "abort" (default), "wait", or "keep".
- clear_cache (bool): Clear KV cache after draining. Default True.
Returns:
200 OK on success.
"""
logger.info("Pausing model %s with options: %s", body.model, body.options)
await self._broadcast_to_replicas(body.model, "pause", kwargs=body.options)
return Response(status_code=200)
async def resume(self, body: ResumeRequest) -> Response:
"""Resume generation on all replicas for the specified model.
Args:
body: Request containing the model ID and engine-specific options.
Returns:
200 OK on success.
"""
logger.info("Resuming model %s with options: %s", body.model, body.options)
await self._broadcast_to_replicas(body.model, "resume", kwargs=body.options)
return Response(status_code=200)
async def is_paused(
self, model: str = Query(..., description="The model ID to check")
) -> IsPausedResponse:
"""Check if the engine is paused for the specified model.
This checks the pause status across all replicas. Returns True if
ANY replica is paused (uses logical OR across replicas).
Args:
model: The model ID to check.
Returns:
IsPausedResponse with is_paused boolean.
"""
results = await self._broadcast_to_replicas(model, "is_paused")
is_paused_result = any(results) if results else False
return IsPausedResponse(is_paused=is_paused_result)
@@ -0,0 +1,112 @@
"""Sleepable ingress mixin.
Provides HTTP endpoints for sleep/wakeup control plane operations.
"""
from typing import Any, Dict
from fastapi import Query
from pydantic import BaseModel, Field
from starlette.responses import Response
from ray.llm._internal.serve.core.ingress.mixins.broadcastable import (
ReplicaBroadcastable,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
# --- Pydantic Models ---
class SleepRequest(BaseModel):
"""Request to put an engine to sleep."""
model: str
options: Dict[str, Any] = Field(
default_factory=dict,
description="Engine-specific sleep options (e.g., level for vLLM)",
)
class WakeupRequest(BaseModel):
"""Request to wake up an engine from sleep."""
model: str
options: Dict[str, Any] = Field(
default_factory=dict,
description="Engine-specific wakeup options (e.g., tags for vLLM)",
)
class IsSleepingResponse(BaseModel):
"""Response indicating whether the engine is sleeping."""
is_sleeping: bool
# --- Mixin ---
class SleepableIngressMixin(ReplicaBroadcastable):
"""Ingress mixin for /sleep, /wakeup, /is_sleeping endpoints.
Adds control plane endpoints for managing engine sleep state.
Sleep mode offloads model weights to CPU and discards KV cache.
"""
ENDPOINTS = {
"sleep": lambda app: app.post("/sleep"),
"wakeup": lambda app: app.post("/wakeup"),
"is_sleeping": lambda app: app.get("/is_sleeping"),
}
async def sleep(self, body: SleepRequest) -> Response:
"""Put the engine to sleep on all replicas for the specified model.
This offloads model weights to CPU and discards KV cache, freeing
GPU memory. The engine cannot process requests while sleeping.
Args:
body: Request containing the model ID and engine-specific options.
Returns:
200 OK on success.
"""
logger.info(
"Putting model %s to sleep with options: %s", body.model, body.options
)
await self._broadcast_to_replicas(body.model, "sleep", kwargs=body.options)
return Response(status_code=200)
async def wakeup(self, body: WakeupRequest) -> Response:
"""Wake up the engine from sleep on all replicas for the specified model.
Args:
body: Request containing the model ID and engine-specific options.
Returns:
200 OK on success.
"""
logger.info("Waking up model %s with options: %s", body.model, body.options)
await self._broadcast_to_replicas(body.model, "wakeup", kwargs=body.options)
return Response(status_code=200)
async def is_sleeping(
self, model: str = Query(..., description="The model ID to check")
) -> IsSleepingResponse:
"""Check if the engine is sleeping for the specified model.
This checks the sleep status across all replicas. Returns True if
ANY replica is sleeping (uses logical OR across replicas).
Args:
model: The model ID to check.
Returns:
IsSleepingResponse with is_sleeping boolean.
"""
results = await self._broadcast_to_replicas(model, "is_sleeping")
is_sleeping_result = any(results) if results else False
return IsSleepingResponse(is_sleeping=is_sleeping_result)
@@ -0,0 +1,210 @@
import json
from types import SimpleNamespace
from typing import List, Optional, Tuple
from fastapi import FastAPI, HTTPException, Request
from ray import serve
from ray.llm._internal.serve.core.ingress.tokenizer import (
REQUEST_TOKEN_IDS_KWARG,
TokenizeError,
Tokenizer,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.serve._private.http_util import _matches_session_id_header
from ray.serve.exceptions import DeploymentUnavailableError
from ray.serve.handle import DeploymentHandle
logger = get_logger(__name__)
_BODY_TRUNCATED_HEADER = "x-body-truncated"
# A request body routes on one of these fields. Body-aware routers read it off
# the namespace; a body without any of them degrades to load-balancing. Extend
# as routers learn to route additional request types.
_ROUTING_KEY_FIELDS = ("messages", "prompt")
router_app = FastAPI()
def _parse_routing_payload(body: bytes) -> Optional[SimpleNamespace]:
"""Wrap a request body as a namespace a body-aware router routes on.
Routers read a routing field (``messages`` or ``prompt``) off the first
positional routing arg, the parsed request the normal ingress forwards.
Direct streaming has only the raw body, so this wraps the parsed body in a
namespace exposing every field by attribute, which a router reads the same
way regardless of request type. Returns ``None`` for an empty, non-object,
unparseable, or keyless body, so the caller falls back to load-balancing.
"""
if not body:
return None
try:
data = json.loads(body)
except (ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
if not any(data.get(field) for field in _ROUTING_KEY_FIELDS):
return None
return SimpleNamespace(**data)
@serve.ingress(router_app)
class LLMRouter:
"""Ingress request router for direct streaming.
When direct streaming is enabled, HAProxy calls /internal/route on this
deployment to get a data plane replica, then forwards traffic directly
to the matching LLMServer replica's backend HTTP port.
Replica selection is delegated to the underlying deployment's configured
request router, and this class translates the resulting pick into a backend
HTTP endpoint.
/internal/route HTTP contract
-----------------------------
Request:
POST /internal/route
Content-Type: application/json
Body: the target ChatCompletions or Completions request payload.
Wrapped in a namespace by ``_parse_routing_payload`` and passed to
``choose_replica`` positionally, exposing the request fields the way
the parsed request does. Body-aware policies then score replicas the
same way on both paths.
Truncated bodies:
HAProxy may forward only a prefix of the body for routing and sets the
``x-body-truncated`` header. A truncated prefix is usually not valid
JSON, so no routing key is derived and the request falls back to the
default load-balanced pick.
Session affinity:
If the client request carried the session-id header configured by
``RAY_SERVE_SESSION_ID_HEADER_KEY`` (default ``x-session-id``),
HAProxy's Lua action forwards it to ``/internal/route`` on the same
name. This handler reads it and applies
``handle.options(session_id=...)`` before calling
``choose_replica`` so session-aware policies (e.g.
``ConsistentHashRouter``) pin all turns of a session to one replica.
Responses:
200 ``{"host": str, "port": int, "replica_id": str}``: pick
succeeded.
4xx/5xx FastAPI ``{"detail": str}``: informational only; HAProxy
treats any non-200 as a routing failure. When using KV aware routing,
a pre-routing ``/tokenize`` rejection is surfaced here.
Health:
``GET /health`` is exposed as a human-operator convenience.
Serve uses ``check_health()`` for replica readiness, not HTTP.
"""
# Warn once per replica when no routing key is derived. Class-level default
# keeps the guard safe before __init__ runs.
_warned_no_routing_key: bool = False
async def __init__(
self, server: DeploymentHandle, pre_routing_tokenization: bool = False
):
self._handle: DeploymentHandle = server
self._handle._init()
# Pre-routing tokenization is only useful to a KV-aware request router,
# which scores replicas based on the prompt token IDs.
self._tokenizer = Tokenizer(self._handle) if pre_routing_tokenization else None
@router_app.post("/internal/route")
async def route(self, request: Request):
body = await request.body()
body_truncated = _BODY_TRUNCATED_HEADER in request.headers
routing_payload = _parse_routing_payload(body)
if routing_payload is None and not self._warned_no_routing_key:
self._warned_no_routing_key = True
logger.warning(
"Could not derive a routing key from the request body. "
"body_truncated=%s. Falling back to load-balanced replica "
"selection. A configured body-aware router such as "
"PrefixCacheAffinityRouter cannot take effect for these "
"requests. For truncated bodies, raise HAProxy's routing body "
"limit.",
body_truncated,
)
# Tokenize only a parseable, routable body; a truncated or unparseable
# body has no routing payload, so fall back to token-less routing.
request_token_ids = None
if self._tokenizer is not None and routing_payload is not None:
try:
request_token_ids = await self._tokenizer.tokenize(
vars(routing_payload)
)
except TokenizeError as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# HAProxy forwards the configured session header on the same name,
# but use the same case-insensitive, separator-tolerant matcher as
# proxy.py / ingress.py so a `-`/`_` rewrite anywhere in the path
# doesn't silently drop session affinity.
session_id = next(
(v for k, v in request.headers.items() if _matches_session_id_header(k)),
None,
)
handle = (
self._handle.options(session_id=session_id) if session_id else self._handle
)
try:
host, port, replica_id = await self._pick_replica(
handle=handle,
routing_payload=routing_payload,
request_token_ids=request_token_ids,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (RuntimeError, DeploymentUnavailableError) as e:
raise HTTPException(status_code=503, detail=str(e))
return {"host": host, "port": port, "replica_id": replica_id}
@router_app.get("/health")
async def health(self):
return {"status": "ok"}
async def _pick_replica(
self,
handle: DeploymentHandle,
routing_payload: Optional[SimpleNamespace] = None,
request_token_ids: Optional[List[int]] = None,
) -> Tuple[str, int, str]:
"""Pick a backend HTTP replica via the deployment's request router.
``handle`` is the LLMServer deployment handle, optionally configured
with ``.options(session_id=...)`` by the caller so session-aware
routers see the session id on ``RequestMetadata``.
``routing_payload``, when present, is passed to ``choose_replica``
positionally. It lands in ``pending_request.args`` where the normal
ingress puts the parsed request, so a body-aware policy scores replicas
as on the normal path. When ``None``, nothing is forwarded. The router
sees empty ``args`` and falls back to its default load-balanced pick.
``request_token_ids``, when present, is forwarded as a keyword arg so a
KV-aware request router can score replicas on prompt-prefix overlap.
``_reserve=False`` short-circuits the replica-side ``reserve_slot``
actor RPC and the rejection-retry loop: the real request goes out via
HAProxy, so Serve's capacity semaphore isn't load-bearing here, and
the extra RPC + retry introduced burstiness compared to the prior
local round-robin implementation.
"""
route_args = (routing_payload,) if routing_payload is not None else ()
choose_replica_kwargs = {"_reserve": False}
if request_token_ids is not None:
choose_replica_kwargs[REQUEST_TOKEN_IDS_KWARG] = request_token_ids
async with handle.choose_replica(
*route_args, **choose_replica_kwargs
) as selection:
replica = selection._replica
endpoint = replica.backend_http_endpoint
if endpoint is None:
raise RuntimeError(
f"replica {selection.replica_id} has no backend HTTP endpoint"
)
host, port = endpoint
return host, port, replica.replica_id.to_full_id_str()
@@ -0,0 +1,124 @@
from typing import Any, Dict, List, Optional, Union
from ray.llm._internal.serve.core.configs.openai_api_models import (
ErrorResponse,
TokenizeChatRequest,
TokenizeCompletionRequest,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.serve.handle import DeploymentHandle
logger = get_logger(__name__)
# choose_replica kwarg carrying the prompt token IDs to KV-aware routers.
REQUEST_TOKEN_IDS_KWARG = "request_token_ids"
class TokenizeError(Exception):
"""The ``/tokenize`` endpoint rejected the request.
Carries vLLM's HTTP ``status_code``, ``message`` and error ``type``.
"""
def __init__(self, message: str, *, status_code: int, type: str):
super().__init__(message)
self.message = message
self.status_code = status_code
self.type = type
class Tokenizer:
"""Tokenizes incoming requests via the replica's ``/tokenize`` endpoint.
Args:
handle: A handle to the LLMServer deployment.
"""
def __init__(self, handle: DeploymentHandle):
self._handle = handle
async def tokenize(self, payload: Dict[str, Any]) -> Optional[List[int]]:
"""Tokenize a request ``payload`` into prompt token IDs.
Args:
payload: The request body, already parsed into a dict by ``LLMRouter``.
Returns:
The prompt token IDs, or ``None`` for bodies that are not routed on.
Raises:
TokenizeError: The ``/tokenize`` endpoint rejected the request.
"""
tok_req = self._build_tokenize_request(payload)
if tok_req is None:
return None
# /tokenize yields a single response; drain the stream fully so the
# handle response is cleaned up.
resp = None
async for chunk in self._handle.options(stream=True).tokenize.remote(
tok_req, None
):
resp = chunk
if resp is None:
raise TokenizeError(
"/tokenize returned no response",
status_code=500,
type="internal_error",
)
if isinstance(resp, ErrorResponse):
raise TokenizeError(
resp.error.message,
status_code=resp.error.code,
type=resp.error.type,
)
return list(resp.tokens)
def _build_tokenize_request(
self, payload: Dict[str, Any]
) -> Optional[Union[TokenizeChatRequest, TokenizeCompletionRequest]]:
"""Build the Tokenize* request for ``payload``.
KV-aware routing sends each request to one replica, scored on a single
prompt's token sequence, so we return ``None`` (the caller falls back to
token-less routing) for bodies that don't have exactly one prompt:
- A non-string ``prompt``: an OpenAI *batch* completion where ``prompt``
is a list, e.g. ``{"prompt": ["q1", "q2"]}`` (or pre-tokenized id
lists). N prompts give N token sequences, so there's no single key to
route the one request on.
TODO (jeffreywang): Support multi-prompt tokenization.
"""
try:
if "messages" in payload:
# Forward every request field the engine renders the prompt from
# so the routing token IDs match the prefill tokens.
return TokenizeChatRequest.model_validate(
{
k: v
for k, v in payload.items()
if k in TokenizeChatRequest.model_fields
}
)
if "prompt" in payload:
if not isinstance(payload["prompt"], str):
# TODO (jeffreywang): Multi-prompt (list) tokenization is unsupported;
# fall back to token-less routing.
return None
return TokenizeCompletionRequest.model_validate(
{
k: v
for k, v in payload.items()
if k in TokenizeCompletionRequest.model_fields
}
)
# Should be unreachable: LLMRouter only routes bodies with messages
# or a prompt (see _parse_routing_payload).
logger.warning(
"Tokenizer got a payload with neither messages nor prompt; "
"falling back to token-less routing."
)
return None
except Exception as e:
logger.debug("Unsupported tokenize request, falling back: %s", e)
return None
@@ -0,0 +1,153 @@
"""Shared helpers for OpenAI ingress, reused by the P/D direct-streaming path."""
import json
from typing import AsyncGenerator, List, Tuple, TypeVar, Union
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionStreamResponse,
CompletionResponse,
CompletionStreamResponse,
TranscriptionResponse,
TranscriptionStreamResponse,
)
T = TypeVar("T")
NON_STREAMING_RESPONSE_TYPES = (
ChatCompletionResponse,
CompletionResponse,
TranscriptionResponse,
)
StreamResponseType = Union[
ChatCompletionStreamResponse, CompletionStreamResponse, TranscriptionStreamResponse
]
BatchedStreamResponseType = List[StreamResponseType]
def _sanitize_chat_completion_request(
request: ChatCompletionRequest,
) -> ChatCompletionRequest:
"""Sanitize ChatCompletionRequest to fix Pydantic ValidatorIterator serialization issue.
This addresses a known Pydantic bug where fields typed as ``Iterable[...]``
on OpenAI message TypedDicts (notably ``content`` on every message variant
and ``tool_calls`` on assistant messages) become ValidatorIterator objects
that cannot be pickled for Ray remote calls.
Workaround logic adapted from vLLM (credits: @gcalmettes):
- vLLM PR: https://github.com/vllm-project/vllm/pull/9951
- Pydantic Issue: https://github.com/pydantic/pydantic/issues/9467
- Related Issue: https://github.com/pydantic/pydantic/issues/9541
- Official Workaround: https://github.com/pydantic/pydantic/issues/9467#issuecomment-2442097291
Note: still reproducible on Pydantic 2.12 for the ``Iterable[...]`` arm of
a ``Union``, so this sanitizer is required regardless of Pydantic version.
"""
for i, message in enumerate(request.messages):
# SGLang messages are Pydantic BaseModels (no .get()); convert to dicts
# so the same logic works for both vLLM (TypedDict) and SGLang.
if not isinstance(message, dict):
request.messages[i] = message = message.model_dump()
# `content` is typed `Union[str, Iterable[ContentPart], None]` on every
# OpenAI message variant. When the iterable arm matches, Pydantic stores
# a non-picklable ValidatorIterator. Materialize it for any role.
content_val = message.get("content")
if content_val is not None and not isinstance(content_val, str):
try:
message["content"] = list(content_val)
except (TypeError, ValueError) as e:
raise ValueError(
"Validating message `content` raised an error. Please "
"ensure `content` is a string, None, or an iterable of "
"content parts."
) from e
if message.get("role") == "assistant":
tool_calls_val = message.get("tool_calls")
if tool_calls_val is not None:
try:
message["tool_calls"] = list(tool_calls_val)
except (TypeError, ValueError) as e:
raise ValueError(
"Validating messages' `tool_calls` raised an error. "
"Please ensure `tool_calls` are iterable of tool calls."
) from e
return request
def _apply_openai_json_format(
response: Union[StreamResponseType, BatchedStreamResponseType],
) -> str:
"""Converts the stream response to OpenAI format.
Each model response is converted to the string:
data: <response-json1>\n\n
The converted strings are concatenated and returned:
data: <response-json1>\n\ndata: <response-json2>\n\n...
"""
if isinstance(response, list):
first_response = next(iter(response))
if isinstance(first_response, str):
return "".join(response)
if isinstance(first_response, dict):
return "".join(f"data: {json.dumps(r)}\n\n" for r in response)
if hasattr(first_response, "model_dump_json"):
return "".join(f"data: {r.model_dump_json()}\n\n" for r in response)
raise ValueError(
f"Unexpected response type: {type(first_response)}, {first_response=}"
)
if hasattr(response, "model_dump_json"):
return f"data: {response.model_dump_json()}\n\n"
if isinstance(response, str):
return response
raise ValueError(f"Unexpected response type: {type(response)}, {response=}")
async def _peek_at_generator(
gen: AsyncGenerator[T, None],
) -> Tuple[T, AsyncGenerator[T, None]]:
# Peek at the first element
first_item = await gen.__anext__()
# Create a new generator that yields the peeked item first
async def new_generator() -> AsyncGenerator[T, None]:
yield first_item
async for item in gen:
yield item
return first_item, new_generator()
async def _openai_json_wrapper(
generator: AsyncGenerator[
Union[StreamResponseType, BatchedStreamResponseType], None
],
) -> AsyncGenerator[str, None]:
"""Wrapper that converts stream responses into OpenAI JSON strings.
Args:
generator: an async generator that yields either individual stream responses
(StreamResponseType) or batches of stream responses (BatchedStreamResponseType).
Each response is converted into OpenAI JSON format and streamed to the client.
For batched responses, the items are concatenated together as a single string.
Yields:
String chunks in OpenAI SSE format: "data: {json}\n\n", with a final
"data: [DONE]\n\n" to indicate completion. If the upstream generator
already yields a "data: [DONE]" sentinel, it is not duplicated.
"""
done_sent = False
async for response in generator:
packet = _apply_openai_json_format(response)
if packet.strip().endswith("data: [DONE]"):
done_sent = True
yield packet
if not done_sent:
yield "data: [DONE]\n\n"
@@ -0,0 +1,208 @@
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
List,
Optional,
Protocol,
Union,
)
from starlette.requests import Request
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,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
DetokenizeRequest,
DetokenizeResponse,
ErrorResponse,
TokenizeRequest,
TokenizeResponse,
)
@dataclass
class RawRequestInfo:
"""A serializable representation of important fields from a Starlette Request.
This dataclass captures key request data that needs to be passed through
RPC boundaries (e.g., from ingress to LLMServer). The Starlette Request
object itself is not serializable, so we extract the needed fields here.
Usage:
raw_request = RawRequestInfo.from_starlette_request(starlette_request)
# Pass raw_request through RPC...
starlette_request = raw_request.to_starlette_request()
"""
headers: Dict[str, str] = field(default_factory=dict)
@classmethod
def from_starlette_request(cls, request: Request) -> "RawRequestInfo":
"""Create a RawRequestInfo from a Starlette Request object."""
return cls(headers=dict(request.headers))
def to_starlette_request(self) -> Request:
"""Create a minimal Starlette Request from this RawRequestInfo."""
scope = {
"type": "http",
"method": "POST",
"path": "/",
"headers": [
(k.lower().encode(), (v or "").encode())
for k, v in self.headers.items()
],
"query_string": b"",
}
return Request(scope)
@classmethod
def to_starlette_request_optional(
cls, raw_request_info: Optional["RawRequestInfo"] = None
) -> Optional[Request]:
"""Convert RawRequestInfo to Starlette Request, or return None if input is None."""
if raw_request_info is not None:
return raw_request_info.to_starlette_request()
return None
class DeploymentProtocol(Protocol):
@classmethod
def get_deployment_options(cls, **kwargs) -> Dict[str, Any]:
"""Get the default deployment options for the this deployment."""
class LLMServerProtocol(DeploymentProtocol):
"""
This is the common interface between all the llm deployment. All llm deployments
need to implement a sync constructor, an async start method, and check_health method.
"""
def __init__(self):
"""
Constructor takes basic setup that doesn't require async operations.
"""
async def start(self) -> None:
"""
Start the underlying engine. This handles async initialization.
"""
async def chat(
self,
request: "ChatCompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, "ChatCompletionResponse", "ErrorResponse"], None]:
"""
Inferencing to the engine for chat, and return the response.
"""
async def completions(
self,
request: "CompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[List[Union[str, "ErrorResponse"]], "CompletionResponse"], None
]:
"""
Inferencing to the engine for completion api, and return the response.
"""
async def check_health(self) -> None:
"""
Check the health of the replica. Does not return anything.
Raise error when the engine is dead and needs to be restarted.
"""
async def reset_prefix_cache(self) -> None:
"""Reset the prefix cache of the underlying engine"""
async def start_profile(self) -> None:
"""Start profiling"""
async def stop_profile(self) -> None:
"""Stop profiling"""
async def sleep(self, **kwargs: Any) -> None:
"""Put the engine to sleep.
Args:
**kwargs: Engine-specific sleep options. Passed through to the engine.
"""
async def wakeup(self, **kwargs: Any) -> None:
"""Wake up the engine from sleep mode.
Args:
**kwargs: Engine-specific wakeup options. Passed through to the engine.
"""
async def is_sleeping(self) -> bool:
"""Check whether the engine is currently sleeping.
Returns:
True if the engine is sleeping, False otherwise.
"""
async def pause(self, **kwargs: Any) -> None:
"""Pause the engine.
Args:
**kwargs: Engine-specific pause options. Passed through to the engine.
"""
async def resume(self, **kwargs: Any) -> None:
"""Resume the engine.
Args:
**kwargs: Engine-specific resume options. Passed through to the engine.
"""
async def is_paused(self) -> bool:
"""Check whether the engine is currently paused.
Returns:
True if the engine is paused, False otherwise.
"""
async def tokenize(
self,
request: "TokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["TokenizeResponse", "ErrorResponse"], None]:
"""Tokenize the input text.
Args:
request: The tokenize request containing the text to tokenize.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator yielding TokenizeResponse or ErrorResponse objects.
"""
async def detokenize(
self,
request: "DetokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["DetokenizeResponse", "ErrorResponse"], None]:
"""Detokenize the input token IDs.
Args:
request: The detokenize request containing the token IDs.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator yielding DetokenizeResponse or ErrorResponse objects.
"""
# TODO (Kourosh): This does not belong here.
async def llm_config(self) -> Optional["LLMConfig"]:
"""Get the LLM config"""
@@ -0,0 +1,89 @@
import pprint
from typing import Optional, Type
from ray import serve
from ray.llm._internal.common.dict_utils import (
maybe_apply_llm_deployment_config_defaults,
)
from ray.llm._internal.serve.constants import (
DEFAULT_HEALTH_CHECK_PERIOD_S,
DEFAULT_HEALTH_CHECK_TIMEOUT_S,
DEFAULT_MAX_ONGOING_REQUESTS,
DEFAULT_MAX_TARGET_ONGOING_REQUESTS,
)
from ray.llm._internal.serve.core.configs.llm_config import (
LLMConfig,
)
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.routing_policies.kv_aware.utils import (
_maybe_setup_kv_aware_routing,
)
from ray.serve.deployment import Application
logger = get_logger(__name__)
DEFAULT_DEPLOYMENT_OPTIONS = {
"max_ongoing_requests": DEFAULT_MAX_ONGOING_REQUESTS,
"health_check_period_s": DEFAULT_HEALTH_CHECK_PERIOD_S,
"health_check_timeout_s": DEFAULT_HEALTH_CHECK_TIMEOUT_S,
"autoscaling_config": {
"target_ongoing_requests": DEFAULT_MAX_TARGET_ONGOING_REQUESTS,
},
}
def _get_deployment_name(llm_config: LLMConfig) -> str:
return llm_config.model_id.replace("/", "--").replace(".", "_")
def build_llm_deployment(
llm_config: LLMConfig,
*,
name_prefix: Optional[str] = None,
bind_kwargs: Optional[dict] = None,
override_serve_options: Optional[dict] = None,
deployment_cls: Optional[Type[LLMServer]] = None,
) -> Application:
"""Build an LLMServer deployment.
Args:
llm_config: The LLMConfig to build the deployment.
name_prefix: The prefix to add to the deployment name.
bind_kwargs: The optional extra kwargs to pass to the deployment.
Used for customizing the deployment.
override_serve_options: The optional serve options to override the
default options.
deployment_cls: The deployment class to use. Defaults to LLMServer.
Returns:
The Ray Serve Application for the LLMServer deployment.
"""
deployment_cls = deployment_cls or llm_config.server_cls or LLMServer
name_prefix = name_prefix or f"{deployment_cls.__name__}:"
bind_kwargs = bind_kwargs or {}
deployment_options = deployment_cls.get_deployment_options(llm_config)
# Set the name of the deployment config to map to the model ID.
deployment_name = deployment_options.get("name", _get_deployment_name(llm_config))
if name_prefix:
deployment_options["name"] = name_prefix + deployment_name
if override_serve_options:
deployment_options.update(override_serve_options)
deployment_options = maybe_apply_llm_deployment_config_defaults(
DEFAULT_DEPLOYMENT_OPTIONS, deployment_options
)
_maybe_setup_kv_aware_routing(deployment_options, llm_config)
logger.info("============== Deployment Options ==============")
logger.info(pprint.pformat(deployment_options))
return serve.deployment(deployment_cls, **deployment_options).bind(
llm_config=llm_config, **bind_kwargs
)
@@ -0,0 +1,791 @@
import asyncio
import copy
import os
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
List,
Optional,
Type,
TypeVar,
Union,
)
import ray
from ray import serve
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
from ray._common.utils import import_attr
from ray.llm._internal.serve.constants import (
ENABLE_WORKER_PROCESS_SETUP_HOOK,
ENGINE_START_TIMEOUT_S,
MODEL_RESPONSE_BATCH_TIMEOUT_MS,
RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING,
RAYLLM_VLLM_ENGINE_CLS_ENV,
)
from ray.llm._internal.serve.core.configs.llm_config import (
DiskMultiplexConfig,
LLMConfig,
)
from ray.llm._internal.serve.core.engine.protocol import LLMEngine
from ray.llm._internal.serve.core.protocol import LLMServerProtocol, RawRequestInfo
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.observability.usage_telemetry.usage import (
push_telemetry_report_for_all_models,
)
from ray.llm._internal.serve.utils.batcher import Batcher
from ray.llm._internal.serve.utils.lora_serve_utils import (
LoraModelLoader,
)
from ray.llm._internal.serve.utils.server_utils import (
get_serve_request_id,
)
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
DetokenizeRequest,
DetokenizeResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorResponse,
ScoreRequest,
ScoreResponse,
TokenizeRequest,
TokenizeResponse,
TranscriptionRequest,
TranscriptionResponse,
)
logger = get_logger(__name__)
T = TypeVar("T")
def _merge_replica_actor_and_child_actor_bundles(
child_actor_bundles: List[Dict[str, float]],
replica_actor_bundle: Dict[str, float],
) -> List[Dict[str, float]]:
"""Sum up the bundles from replica actor bundles with the first bundle from child actor bundles.
This is because the replica actor will use the first bundle in the list, and we want to collocate the replica actor with the child actor.
So we need to group them together.
So for example:
child_actor_bundles = [{"GPU": 1, "CPU": 1}, {"GPU": 1, "CPU": 1}]
replica_actor_bundle = {"GPU": 0, "CPU": 1, "memory": 100}
return [{"GPU": 1, "CPU": 2, "memory": 100}, {"GPU": 1, "CPU": 1}]
"""
if not child_actor_bundles:
return [copy.copy(replica_actor_bundle)]
if not replica_actor_bundle:
return [copy.copy(bundle) for bundle in child_actor_bundles]
original_first_bundle = child_actor_bundles[0]
bundle_key_set = set(original_first_bundle.keys()) | set(
replica_actor_bundle.keys()
)
merged_first_bundle = {
key: original_first_bundle.get(key, 0) + replica_actor_bundle.get(key, 0)
for key in bundle_key_set
}
return [merged_first_bundle] + [
copy.copy(bundle) for bundle in child_actor_bundles[1:]
]
class LLMServer(LLMServerProtocol):
"""This is a shim layer to decouple the LLM engine from the ingress
deployment.
It has a very similar API as the engine. Almost all of the abstractions are
implemented by the engine. This class just a little bit more logic on top:
1. Logic for serve multiplexing (e.g. LoRA loading).
2. Request id handing from serve context.
3. Batching in case of streaming (only for chat and completions).
4. Telemetry reporting.
Usage Patterns:
1. Basic pattern (for testing):
server = LLMServer.sync_init(llm_config) # Sync constructor, unstarted
await server.start() # Must explicitly start
2. Async context (default, used by Ray Serve):
server = await LLMServer(llm_config) # Async constructor, fully started
3. Ray Serve deployment:
# Ray Serve calls the async constructor directly
deployment = serve.deployment(LLMServer).bind(llm_config)
"""
_default_engine_cls = None
async def __init__(
self,
llm_config: LLMConfig,
*,
engine_cls: Optional[Type[LLMEngine]] = None,
model_downloader: Optional[Type[LoraModelLoader]] = None,
):
"""Asynchronous constructor that returns a fully started instance.
This is the default constructor used by Ray Serve deployments.
Args:
llm_config: LLMConfig for the model.
engine_cls: Dependency injection for the vllm engine class.
Defaults to `VLLMEngine`.
model_downloader: Dependency injection for the model downloader.
Defaults to `LoraModelLoader`.
"""
super().__init__()
self._init_shared(llm_config, engine_cls, model_downloader)
await self.start()
def _init_shared(
self,
llm_config: LLMConfig,
engine_cls: Optional[Type[LLMEngine]] = None,
model_downloader: Optional[Type[LoraModelLoader]] = None,
):
"""Shared initialization logic between constructors."""
self._llm_config = llm_config
self._engine_cls = engine_cls or self._get_default_engine_class()
self.engine: Optional[LLMEngine] = None
self._init_multiplex_loader(model_downloader)
@classmethod
def sync_init(
cls,
llm_config: LLMConfig,
*,
engine_cls: Optional[Type[LLMEngine]] = None,
model_downloader: Optional[Type[LoraModelLoader]] = None,
) -> "LLMServer":
"""Synchronous constructor that returns an unstarted instance.
This is used for testing the new pattern where initialization
and starting are explicitly separated.
Args:
llm_config: LLMConfig for the model.
engine_cls: Dependency injection for the vllm engine class.
Defaults to `VLLMEngine`.
model_downloader: Dependency injection for the model downloader.
Defaults to `LoraModelLoader`.
Returns:
An unstarted LLMServer instance. Caller must call await start().
"""
instance = cls.__new__(cls)
LLMServerProtocol.__init__(instance)
instance._init_shared(llm_config, engine_cls, model_downloader)
return instance
async def start(self):
"""Start the underlying engine. This handles async initialization."""
if self._engine_cls is not None:
self.engine = self._engine_cls(self._llm_config)
await asyncio.wait_for(self._start_engine(), timeout=ENGINE_START_TIMEOUT_S)
async def __serve_build_asgi_app__(self):
from fastapi import HTTPException
from ray.llm._internal.serve.core.configs.openai_api_models import (
ModelCard,
to_model_metadata,
)
app = await self.engine.build_asgi_app()
# vLLM's native ASGI app only exposes `GET /v1/models` (list); add
# `GET /v1/models/{id}` so direct-streaming clients can call
# `openai_client.models.retrieve(...)` like the OpenAiIngress path.
model_id = self._llm_config.model_id
model_card = to_model_metadata(model_id, self._llm_config)
@app.get("/v1/models/{model:path}", response_model=ModelCard)
async def _get_model(model: str):
if model != model_id:
raise HTTPException(status_code=404, detail=f"Unknown model: {model}")
return model_card
return app
def _init_multiplex_loader(
self, model_downloader_cls: Optional[Type[LoraModelLoader]] = None
):
"""Initialize the multiplex loader."""
model_downloader_cls = model_downloader_cls or LoraModelLoader
mx_config = self._llm_config.multiplex_config()
if mx_config is not None:
model_downloader = model_downloader_cls(
download_timeout_s=mx_config.download_timeout_s,
max_tries=mx_config.max_download_tries,
)
async def _load_model(lora_model_id: str) -> DiskMultiplexConfig:
return await model_downloader.load_model_from_config(
lora_model_id=lora_model_id,
llm_config=self._llm_config,
)
self._load_model = serve.multiplexed(
max_num_models_per_replica=mx_config.max_num_models_per_replica
)(_load_model)
else:
async def _load_model(lora_model_id: str) -> DiskMultiplexConfig:
raise ValueError("LoRA config is not set in the LLMConfig")
self._load_model = _load_model
def _get_default_engine_class(self) -> Type[LLMEngine]:
"""Helper to load the engine class from the environment variable.
This is used for testing or escape-hatch for patching purposes.
If env variable is not set, it will fallback to the default engine class
(VLLMEngine, imported lazily to avoid a hard module-level dependency).
"""
engine_cls_path = os.environ.get(RAYLLM_VLLM_ENGINE_CLS_ENV)
if engine_cls_path:
return import_attr(engine_cls_path)
if self._default_engine_cls is not None:
return self._default_engine_cls
from ray.llm._internal.serve.engines.vllm.vllm_engine import VLLMEngine
return VLLMEngine
async def _start_engine(self):
if self.engine is None:
raise ValueError("Engine is not set")
await self.engine.start()
# Push telemetry reports for the model in the current deployment.
push_telemetry_report_for_all_models(all_models=[self._llm_config])
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
# Cluster-wide adoption signal: written from each replica on engine
# start, but last-write-wins so it reports one value per cluster.
record_extra_usage_tag(TagKey.LLM_SERVE_DIRECT_STREAMING_ENABLED, "1")
def _get_batch_interval_ms(self, stream: bool = True) -> int:
"""Calculate the batching interval for responses."""
stream_batching_interval_ms = self._llm_config.experimental_configs.get(
"stream_batching_interval_ms"
)
if stream_batching_interval_ms is None:
stream_batching_interval_ms = MODEL_RESPONSE_BATCH_TIMEOUT_MS
return stream_batching_interval_ms if stream else None
async def _maybe_add_request_id_to_request(
self,
request: Union[
"ChatCompletionRequest",
"CompletionRequest",
"EmbeddingRequest",
"TranscriptionRequest",
],
):
"""Stamp the Serve request id, unless the caller set request_id explicitly.
request_id defaults to a random uuid (never None), so use model_fields_set
to avoid clobbering an id a caller deliberately set (e.g. a P/D connector's
coordination id). Some request types (tokenize/detokenize) have no
request_id field at all -- skip those.
"""
if not hasattr(request, "request_id"):
return
if "request_id" in request.model_fields_set:
return
request_id = get_serve_request_id()
if request_id:
request.request_id = request_id
async def _maybe_resolve_lora_from_multiplex(self) -> None:
"""Handle the lora model for the request."""
multiplexed_model_id = serve.get_multiplexed_model_id()
if multiplexed_model_id:
if self._llm_config.lora_config is None:
raise ValueError("Must setup lora config for multiplexed requests.")
disk_lora_model = await self._load_model(multiplexed_model_id)
await self.engine.resolve_lora(disk_lora_model)
def _batch_output_stream(
self, generator: AsyncGenerator[T, None]
) -> AsyncGenerator[List[T], None]:
return Batcher(
generator,
interval_ms=self._get_batch_interval_ms(),
).stream()
async def _run_request(
self,
request: Union[
"ChatCompletionRequest",
"CompletionRequest",
"EmbeddingRequest",
"TranscriptionRequest",
"ScoreRequest",
],
*,
engine_method: str,
batch_output_stream: bool = False,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Any, None]:
"""Run the engine method on the request + perform batching when stream=True.
Args:
request: The request to run.
engine_method: The method to call on the engine.
batch_output_stream: Whether to batch the output stream.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator of the response. If stream is True and batching is enabled, then the generator will yield a list of streaming responses (strings of the format data: {response_json}\n\n). Otherwise, it will yield the non-streaming response from engine directly.
"""
await self._maybe_add_request_id_to_request(request)
await self._maybe_resolve_lora_from_multiplex()
is_stream = hasattr(request, "stream") and request.stream
engine_stream = getattr(self.engine, engine_method)(request, raw_request_info)
if is_stream and batch_output_stream:
stream = self._batch_output_stream(engine_stream)
else:
stream = engine_stream
return stream
async def chat(
self,
request: "ChatCompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[List[Union[str, "ErrorResponse"]], "ChatCompletionResponse"], None
]:
"""Runs a chat request to the LLM engine and returns the response.
Args:
request: A ChatCompletionRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator of the response. If stream is True and batching
is enabled, then the generator will yield a list of chat streaming
responses (strings of the format data: {response_json}\\n\\n).
Otherwise, it will yield the ChatCompletionResponse object directly.
"""
return await self._run_request(
request,
engine_method="chat",
batch_output_stream=True,
raw_request_info=raw_request_info,
)
async def completions(
self,
request: "CompletionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[List[Union[str, "ErrorResponse"]], "CompletionResponse"], None
]:
"""Runs a completion request to the LLM engine and returns the response.
Args:
request: A CompletionRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator of the response. If stream is True and batching
is enabled, then the generator will yield a list of completion
streaming responses (strings of the format data: {response_json}\\n\\n).
Otherwise, it will yield the CompletionResponse object directly.
"""
return await self._run_request(
request,
engine_method="completions",
batch_output_stream=True,
raw_request_info=raw_request_info,
)
async def embeddings(
self,
request: "EmbeddingRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[List["ErrorResponse"], "EmbeddingResponse"], None]:
"""Runs an embeddings request to the engine and returns the response.
Returns an AsyncGenerator over the EmbeddingResponse object. This is so that the caller can have a consistent interface across all the methods of chat, completions, embeddings and transcriptions.
Args:
request: An EmbeddingRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator over the EmbeddingResponse object.
"""
# NOTE: Embeddings does not need batching.
return await self._run_request(
request,
engine_method="embeddings",
batch_output_stream=False,
raw_request_info=raw_request_info,
)
async def transcriptions(
self,
request: "TranscriptionRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[List[Union[str, "ErrorResponse"]], "TranscriptionResponse"], None
]:
"""Runs an transcriptions request to the engine and returns the response.
Returns an AsyncGenerator over the TranscriptionResponse object. This is so that the caller can have a consistent interface across all the methods of chat, completions, embeddings and transcriptions.
Args:
request: A TranscriptionRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator over the TranscriptionResponse object.
"""
return await self._run_request(
request,
engine_method="transcriptions",
batch_output_stream=True,
raw_request_info=raw_request_info,
)
async def score(
self,
request: "ScoreRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["ScoreResponse", "ErrorResponse"], None]:
"""Runs a score request to the engine and returns the response.
Returns an AsyncGenerator over the ScoreResponse object. This is so that the caller can have a consistent interface across all the methods of chat, completions, embeddings, and score.
Args:
request: A ScoreRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator over the ScoreResponse object.
"""
# NOTE: Score does not need batching, similar to embeddings.
return await self._run_request(
request,
engine_method="score",
batch_output_stream=False,
raw_request_info=raw_request_info,
)
async def tokenize(
self,
request: "TokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["TokenizeResponse", "ErrorResponse"], None]:
"""Tokenize the input text.
Args:
request: A TokenizeRequest object (TokenizeCompletionRequest or TokenizeChatRequest).
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator over the TokenizeResponse object.
"""
# NOTE: Tokenize does not need batching.
return await self._run_request(
request,
engine_method="tokenize",
batch_output_stream=False,
raw_request_info=raw_request_info,
)
async def detokenize(
self,
request: "DetokenizeRequest",
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union["DetokenizeResponse", "ErrorResponse"], None]:
"""Detokenize the input token IDs.
Args:
request: A DetokenizeRequest object.
raw_request_info: Optional RawRequestInfo containing data from the original
HTTP request.
Returns:
An AsyncGenerator over the DetokenizeResponse object.
"""
# NOTE: Detokenize does not need batching.
return await self._run_request(
request,
engine_method="detokenize",
batch_output_stream=False,
raw_request_info=raw_request_info,
)
async def check_health(self) -> None:
"""
Check the health of the replica. Does not return anything. Raise error when
the engine is dead and needs to be restarted.
"""
if self.engine is None:
return
try:
return await self.engine.check_health()
except Exception as e:
logger.error("Engine health check failed in LLMServer.check_health: %s", e)
raise e
async def record_routing_stats(self) -> Dict[str, Any]:
"""Serve request-router hook, polled by the controller.
Surfaces this replica's routing stats (the engine's KV-events endpoint
for KV-aware routing); the deployment's ``KVRouterActor`` reads them off
the ``LongPoll`` replica snapshot to register the worker.
"""
if self.engine is None:
return {}
return self.engine.routing_stats()
async def sleep(self, **kwargs: Any) -> None:
"""Put the engine to sleep.
Args:
**kwargs: Engine-specific sleep options. Passed through to the engine.
"""
if self.engine is None:
return
try:
await self.engine.sleep(**kwargs)
except Exception as e:
logger.error("Engine sleep failed in LLMServer.sleep: %s", e)
raise e
async def wakeup(self, **kwargs: Any) -> None:
"""Wake up the engine from sleep mode.
Args:
**kwargs: Engine-specific wakeup options. Passed through to the engine.
"""
if self.engine is None:
return
try:
await self.engine.wakeup(**kwargs)
except Exception as e:
logger.error("Engine wakeup failed in LLMServer.wakeup: %s", e)
raise e
async def is_sleeping(self) -> bool:
"""Check whether the engine is currently sleeping.
Returns:
True if the engine is sleeping, False otherwise.
"""
if self.engine is None:
return False
try:
return await self.engine.is_sleeping()
except Exception as e:
logger.error("Engine is_sleeping failed in LLMServer.is_sleeping: %s", e)
raise e
async def reset_prefix_cache(self) -> None:
"""Reset the KV prefix cache on the engine.
Clears cached key-value pairs from previous requests.
"""
if self.engine is None:
return
try:
await self.engine.reset_prefix_cache()
except Exception as e:
logger.error(
"Engine reset_prefix_cache failed in LLMServer.reset_prefix_cache: %s",
e,
)
raise e
async def pause(self, **kwargs: Any) -> None:
"""Pause generation on the engine.
This halts generation requests while keeping model weights
in GPU memory. New requests are blocked until resume is called.
Args:
**kwargs: Engine-specific pause options. Passed through to the engine.
"""
if self.engine is None:
return
try:
await self.engine.pause(**kwargs)
except Exception as e:
logger.error("Engine pause failed in LLMServer.pause: %s", e)
raise e
async def resume(self, **kwargs: Any) -> None:
"""Resume generation on the engine after pause.
Args:
**kwargs: Engine-specific resume options. Passed through to the engine.
"""
if self.engine is None:
return
try:
await self.engine.resume(**kwargs)
except Exception as e:
logger.error("Engine resume failed in LLMServer.resume: %s", e)
raise e
async def is_paused(self) -> bool:
"""Check whether the engine is currently paused.
Returns:
True if the engine is paused, False otherwise.
"""
if self.engine is None:
return False
try:
return await self.engine.is_paused()
except Exception as e:
logger.error("Engine is_paused failed in LLMServer.is_paused: %s", e)
raise e
async def start_profile(self) -> None:
"""Start profiling"""
if self.engine is None:
return
try:
await self.engine.start_profile()
except Exception as e:
logger.error(
"Engine start profile failed in LLMServer.start_profile: %s", e
)
raise e
async def stop_profile(self) -> None:
"""Stop profiling"""
if self.engine is None:
return
try:
await self.engine.stop_profile()
except Exception as e:
logger.error("Engine stop profile failed in LLMServer.stop_profile: %s", e)
raise e
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 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.
"""
if self.engine is None:
return []
try:
return await self.engine.collective_rpc(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
)
except Exception as e:
logger.error(
"Engine collective_rpc failed in LLMServer.collective_rpc: %s", e
)
raise e
async def llm_config(self) -> Optional[LLMConfig]:
return self._llm_config
@classmethod
def get_deployment_options(cls, llm_config: "LLMConfig"):
engine_config = llm_config.get_engine_config()
deployment_options = copy.deepcopy(llm_config.deployment_config)
if (
"placement_group_bundles" in llm_config.deployment_config
or "placement_group_strategy" in llm_config.deployment_config
):
raise ValueError(
"placement_group_bundles and placement_group_strategy must not be specified in deployment_config. You can override the default values by setting the `placement_group_config` in the LLMConfig."
)
# Handle the ray_actor_options that could be passed in to
# deployment_options
ray_actor_options = deployment_options.get("ray_actor_options", {})
if not engine_config.accelerator.requires_deferred_placement_group:
replica_actor_resources = {
"CPU": ray_actor_options.get("num_cpus", 1),
"GPU": ray_actor_options.get("num_gpus", 0),
**ray_actor_options.get("resources", {}),
}
if "memory" in ray_actor_options:
replica_actor_resources["memory"] = ray_actor_options["memory"]
# TODO: Move this _merge_replica_actor_and_child_actor_bundles to a
# more generic place.
pg_bundles = _merge_replica_actor_and_child_actor_bundles(
engine_config.placement_bundles, replica_actor_resources
)
deployment_options.update(
{
"placement_group_bundles": pg_bundles,
"placement_group_strategy": engine_config.placement_strategy,
}
)
# Handle env vars from runtime_env
default_runtime_env = ray.get_runtime_context().runtime_env
if ENABLE_WORKER_PROCESS_SETUP_HOOK:
default_runtime_env[
"worker_process_setup_hook"
] = "ray.llm._internal.serve._worker_process_setup_hook"
ray_actor_options = deployment_options.get("ray_actor_options", {})
ray_actor_options["runtime_env"] = {
**default_runtime_env,
# Existing runtime_env should take precedence over the default.
**ray_actor_options.get("runtime_env", {}),
**(llm_config.runtime_env if llm_config.runtime_env else {}),
}
deployment_options["ray_actor_options"] = ray_actor_options
return deployment_options
@@ -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
@@ -0,0 +1,23 @@
from ray.llm._internal.common.observability.logging_utils import (
disable_datasets_logger,
disable_vllm_custom_ops_logger_on_cpu_nodes,
)
from ray.llm._internal.common.observability.telemetry_utils import Once
from ray.llm._internal.serve.observability.logging.setup import (
setup_logging,
)
_setup_observability_once = Once()
def _setup_observability():
setup_logging()
disable_datasets_logger()
disable_vllm_custom_ops_logger_on_cpu_nodes()
def setup_observability():
_setup_observability_once.do_once(_setup_observability)
__all__ = ["setup_observability"]
@@ -0,0 +1,39 @@
import logging
from typing import Optional
from ray._common.filters import CoreContextFilter
from ray.serve._private.logging_utils import ServeContextFilter
def _setup_logger(logger_name: str):
"""Setup logger given the logger name.
This function is idempotent and won't set up the same logger multiple times. It will
Also skip the setup if Serve logger is already setup and has handlers.
"""
logger = logging.getLogger(logger_name)
serve_logger = logging.getLogger("ray.serve")
# Skip setup if the logger already has handlers setup or if the parent (Serve
# logger) has handlers.
if logger.handlers or serve_logger.handlers:
return
# Set up stream handler, which logs to console as plaintext.
stream_handler = logging.StreamHandler()
stream_handler.addFilter(CoreContextFilter())
stream_handler.addFilter(ServeContextFilter())
logger.addHandler(stream_handler)
logger.setLevel(logging.INFO)
logger.propagate = False
def get_logger(name: Optional[str] = None):
"""Get a structured logger inherited from the Ray Serve logger.
Loggers by default are logging to stdout, and are expected to be scraped by an
external process.
"""
logger_name = f"ray.serve.{name}"
_setup_logger(logger_name)
return logging.getLogger(logger_name)
@@ -0,0 +1,28 @@
import logging
from ray._common.filters import CoreContextFilter
from ray._common.formatters import JSONFormatter
from ray.serve._private.logging_utils import ServeContextFilter
def _configure_stdlib_logging():
"""Configures stdlib root logger to make sure stdlib loggers (created as
`logging.getLogger(...)`) are using Ray's `JSONFormatter` with Core and Serve
context filters.
"""
handler = logging.StreamHandler()
handler.addFilter(CoreContextFilter())
handler.addFilter(ServeContextFilter())
handler.setFormatter(JSONFormatter())
root_logger = logging.getLogger()
# NOTE: It's crucial we reset all the handlers of the root logger,
# to make sure that logs aren't emitted twice
root_logger.handlers = []
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
def setup_logging():
_configure_stdlib_logging()
@@ -0,0 +1,77 @@
import asyncio
import time
from typing import Dict
from ray.llm._internal.serve.observability.logging import get_logger
from ray.util import metrics
logger = get_logger(__name__)
_METRICS_LOOP_INTERVAL = 5 # 5 seconds
EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES = [
0.05,
0.1,
0.15,
0.20,
0.25,
0.5,
0.75,
1.0,
1.5,
2.0,
3.0,
5.0,
10.0,
15.0,
20.0,
30.0,
45.0,
60.0,
90.0,
120.0,
150.0,
180.0,
300.0,
600.0,
]
def setup_event_loop_monitoring(
loop: asyncio.AbstractEventLoop,
scheduling_latency: metrics.Histogram,
iterations: metrics.Counter,
tasks: metrics.Gauge,
tags: Dict[str, str],
) -> asyncio.Task:
return asyncio.create_task(
_run_monitoring_loop(
loop,
schedule_latency=scheduling_latency,
iterations=iterations,
task_gauge=tasks,
tags=tags,
)
)
async def _run_monitoring_loop(
loop: asyncio.AbstractEventLoop,
schedule_latency: metrics.Histogram,
iterations: metrics.Counter,
task_gauge: metrics.Gauge,
tags: Dict[str, str],
) -> None:
while loop.is_running():
iterations.inc(1, tags)
num_tasks = len(asyncio.all_tasks())
task_gauge.set(num_tasks, tags)
yield_time = time.monotonic()
await asyncio.sleep(_METRICS_LOOP_INTERVAL)
elapsed_time = time.monotonic() - yield_time
# Historically, Ray's implementation of histograms are extremely finicky
# with non-positive values (https://github.com/ray-project/ray/issues/26698).
# Technically it shouldn't be possible for this to be negative, add the
# max just to be safe.
latency = max(0.0, elapsed_time - _METRICS_LOOP_INTERVAL)
schedule_latency.observe(latency, tags)
@@ -0,0 +1,133 @@
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from ray.llm._internal.serve.constants import ENABLE_VERBOSE_TELEMETRY
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.observability.metrics.event_loop_monitoring import (
EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES,
setup_event_loop_monitoring,
)
from ray.llm._internal.serve.observability.metrics.fastapi_utils import (
FASTAPI_API_SERVER_TAG_KEY,
FASTAPI_BASE_HTTP_METRIC_TAG_KEYS,
get_app_name,
)
from ray.llm._internal.serve.observability.metrics.middleware import (
MeasureHTTPRequestMetricsMiddleware,
)
from ray.util import metrics
logger = get_logger(__name__)
ray_llm_build_info_gauge = metrics.Gauge(
"ray_llm_build_info",
description="Metadata about the ray-llm build.",
tag_keys=("git_commit",),
)
_HTTP_HANDLER_LATENCY_S_HISTOGRAM_BUCKETS = [
0.01,
0.05,
0.1,
0.25,
0.5,
0.75,
1,
1.5,
2,
5,
10,
30,
60,
120,
300,
]
async def add_fastapi_event_loop_monitoring(app: FastAPI):
tags = {FASTAPI_API_SERVER_TAG_KEY: get_app_name(app)}
tag_keys = tuple(tags.keys())
# Store the task handle to prevent it from being garbage collected
app.state.fastapi_event_loop_schedule_latency = metrics.Histogram(
"fastapi_event_loop_schedule_latency",
description="Latency of getting yielded control on the FastAPI event loop in seconds",
boundaries=EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES,
tag_keys=tag_keys,
)
app.state.fastapi_event_loop_monitoring_iterations = metrics.Counter(
"fastapi_event_loop_monitoring_iterations",
description="Number of times the FastAPI event loop has iterated to get anyscale_fastapi_event_loop_schedule_latency.",
tag_keys=tag_keys,
)
app.state.fastapi_event_loop_monitoring_tasks = metrics.Gauge(
"fastapi_event_loop_monitoring_tasks",
description="Number of outstanding tasks on the FastAPI event loop.",
tag_keys=tag_keys,
)
app.state.fastapi_event_loop_schedule_latency_metrics_task = (
setup_event_loop_monitoring(
asyncio.get_running_loop(),
app.state.fastapi_event_loop_schedule_latency,
app.state.fastapi_event_loop_monitoring_iterations,
app.state.fastapi_event_loop_monitoring_tasks,
tags,
)
)
def add_http_metrics_middleware(app: FastAPI):
if not ENABLE_VERBOSE_TELEMETRY:
logger.debug(
"ENABLE_VERBOSE_TELEMETRY is false, not setting up FastAPI telemetry"
)
return
logger.info("ENABLE_VERBOSE_TELEMETRY is true, setting up FastAPI telemetry")
base_tag_keys = FASTAPI_BASE_HTTP_METRIC_TAG_KEYS
logger.debug("Setting up FastAPI telemetry")
app.state.http_requests_metrics = metrics.Counter(
"http_requests",
description=(
"Total number of HTTP requests by status code, handler and method."
),
tag_keys=base_tag_keys,
)
# NOTE: Custom decorators are not applied to histogram-based metrics
# to make sure we can keep cardinality of those in check
app.state.http_requests_latency_metrics = metrics.Histogram(
"http_request_duration_seconds",
description="Duration in seconds of HTTP requests.",
boundaries=_HTTP_HANDLER_LATENCY_S_HISTOGRAM_BUCKETS,
tag_keys=base_tag_keys,
)
app.add_middleware(MeasureHTTPRequestMetricsMiddleware)
logger.debug("Setting up FastAPI telemetry completed")
async def set_ray_llm_build_info():
git_commit = os.environ.get("GIT_COMMIT")
if git_commit:
tags = {"git_commit": git_commit}
ray_llm_build_info_gauge.set(1, tags)
@asynccontextmanager
async def metrics_lifespan(app: FastAPI):
"""Lifespan for a FastAPI app that sets up metrics observability."""
if ENABLE_VERBOSE_TELEMETRY:
await add_fastapi_event_loop_monitoring(app)
await set_ray_llm_build_info()
yield
@@ -0,0 +1,26 @@
"""This file contains constants and utility functions for FastAPI."""
from fastapi import FastAPI
# These tag keys are used in metrics for the FastAPI app.
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY = "code"
FASTAPI_HTTP_HANDLER_TAG_KEY = "handler"
FASTAPI_HTTP_METHOD_TAG_KEY = "method"
FASTAPI_HTTP_PATH_TAG_KEY = "path"
FASTAPI_HTTP_USER_ID_TAG_KEY = "user_id"
FASTAPI_API_SERVER_TAG_KEY = "api_server"
FASTAPI_BASE_HTTP_METRIC_TAG_KEYS = (
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY,
FASTAPI_HTTP_HANDLER_TAG_KEY,
FASTAPI_HTTP_METHOD_TAG_KEY,
FASTAPI_HTTP_PATH_TAG_KEY,
FASTAPI_HTTP_USER_ID_TAG_KEY,
FASTAPI_API_SERVER_TAG_KEY,
)
def get_app_name(app: FastAPI) -> str:
"""Gets the FastAPI app name."""
return getattr(app.state, "name", "unknown")
@@ -0,0 +1,150 @@
import time
from asyncio import CancelledError
from typing import Dict, Optional
from fastapi import FastAPI
from starlette.requests import Request
from starlette.types import Message
from ray.llm._internal.serve.core.ingress.middleware import (
get_request_id,
get_user_id,
)
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.observability.metrics.fastapi_utils import (
FASTAPI_API_SERVER_TAG_KEY,
FASTAPI_HTTP_HANDLER_TAG_KEY,
FASTAPI_HTTP_METHOD_TAG_KEY,
FASTAPI_HTTP_PATH_TAG_KEY,
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY,
FASTAPI_HTTP_USER_ID_TAG_KEY,
get_app_name,
)
from ray.serve._private.thirdparty.get_asgi_route_name import _get_route_name
logger = get_logger("ray.serve")
class MeasureHTTPRequestMetricsMiddleware:
"""Measures and stores HTTP request metrics."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
# If the status_code isn't set by send_wrapper,
# we should consider that an error.
status_code = 500
send_wrapper_failed_exc_info = "Status code was never set by send_wrapper."
exception_info = send_wrapper_failed_exc_info
async def send_wrapper(message: Message) -> None:
"""Wraps the send message.
Enables this middleware to access the response headers.
"""
nonlocal status_code, exception_info
if message["type"] == "http.response.start":
status_code = message.get("status", 500)
# Clear the send_wrapper_failed_exc_info.
if exception_info == send_wrapper_failed_exc_info:
exception_info = None
await send(message)
request = Request(scope)
req_id = get_request_id(request)
now = time.monotonic()
try:
logger.info(f"Starting handling of the request {req_id}")
await self.app(scope, receive, send_wrapper)
except CancelledError as ce:
status_code = -1
exception_info = ce
raise
except BaseException as e:
status_code = 500
exception_info = e
raise
finally:
duration_s = time.monotonic() - now
tags = _get_tags(request, status_code, request.app)
# NOTE: Custom decorators are not applied to histogram-based metrics
# to make sure we can keep cardinality of those in check
truncated_tags = {
**tags,
FASTAPI_HTTP_USER_ID_TAG_KEY: "truncated",
}
request.app.state.http_requests_metrics.inc(1, tags)
request.app.state.http_requests_latency_metrics.observe(
duration_s, truncated_tags
)
extra_context = {
"status_code": status_code,
"duration_ms": duration_s * 1000,
}
if status_code >= 400:
log = logger.error if status_code >= 500 else logger.warning
log(
f"Handling of the request {req_id} failed",
exc_info=exception_info,
extra={"ray_serve_extra_fields": extra_context},
)
elif status_code == -1:
logger.info(
f"Handling of the request {req_id} have been cancelled",
extra={"ray_serve_extra_fields": extra_context},
)
else:
logger.info(
f"Handling of the request {req_id} successfully completed",
extra={"ray_serve_extra_fields": extra_context},
)
def _get_route_details(scope: dict) -> Optional[str]:
"""
Function to retrieve Starlette route from scope.
TODO: there is currently no way to retrieve http.route from
a starlette application from scope.
See: https://github.com/encode/starlette/pull/804
Args:
scope: A Starlette scope
Returns:
A string containing the route or None
"""
# Delegate to Serve's shared route-name resolver, which walks the route tree
# and handles FastAPI >= 0.137 `_IncludedRouter` nodes (added by
# `include_router`) that have no `.path` attribute of their own. Accessing
# `.path` on such a node previously raised AttributeError here (#64245).
return _get_route_name(scope, scope["app"].routes)
def _get_tags(request: Request, status_code: int, app: FastAPI) -> Dict[str, str]:
"""Generates tags for the request's metrics."""
route = str(_get_route_details(request.scope)) or "unknown"
path = str(request.url.path) or "unknown"
method = str(request.method) or "unknown"
user_id = str(get_user_id(request) or "unknown")
return {
FASTAPI_API_SERVER_TAG_KEY: get_app_name(app),
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY: str(status_code),
FASTAPI_HTTP_PATH_TAG_KEY: path,
FASTAPI_HTTP_HANDLER_TAG_KEY: route,
FASTAPI_HTTP_METHOD_TAG_KEY: method,
FASTAPI_HTTP_USER_ID_TAG_KEY: user_id,
}
@@ -0,0 +1,144 @@
import time
from enum import Enum
from typing import (
AsyncGenerator,
Callable,
List,
Set,
TypeVar,
)
from ray.util import metrics
# Histogram buckets for short-range latencies measurements:
# Min=1ms, Max=30s
#
# NOTE: Number of buckets have to be bounded (and not exceed 30)
# to avoid overloading metrics sub-system
SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS: List[float] = [
1,
5,
10,
25,
50,
100,
150,
250,
500,
1000,
1500,
2500,
5000,
7500,
10000,
20000,
30000,
]
# Histogram buckets for long-range latencies measurements:
# Min=10ms, Max=300s
LONG_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS = [
x * 10 for x in SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS
]
class ClockUnit(int, Enum):
ms = 1000
s = 1
class MsClock:
"""A clock that tracks intervals in milliseconds"""
def __init__(self, unit: ClockUnit = ClockUnit.ms):
self.reset()
self.unit = unit.value
self.start_time = time.perf_counter()
def reset(self):
self.start_time = time.perf_counter()
def interval(self):
return (time.perf_counter() - self.start_time) * self.unit
def reset_interval(self):
interval = self.interval()
self.reset()
return interval
T = TypeVar("T")
class InstrumentTokenAsyncGenerator:
"""This class instruments an asynchronous generator.
It gathers 3 metrics:
1. Time to first time
2. Time between tokens
3. Total completion time
Usage:
@InstrumentTokenAsyncGenerator("my_special_fn")
async def to_instrument():
yield ...
"""
all_instrument_names: Set[str] = set()
def __init__(
self, generator_name: str, latency_histogram_buckets: List[float] = None
):
self.generator_name = f"rayllm_{generator_name}"
target_latency_histogram_buckets = (
latency_histogram_buckets or SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS
)
assert (
self.generator_name not in self.all_instrument_names
), "This generator name was already used elsewhere. Please specify another one."
self.all_instrument_names.add(self.generator_name)
self.token_latency_histogram = metrics.Histogram(
f"{self.generator_name}_per_token_latency_ms",
f"Generator metrics for {self.generator_name}",
boundaries=target_latency_histogram_buckets,
)
self.first_token_latency_histogram = metrics.Histogram(
f"{self.generator_name}_first_token_latency_ms",
f"Generator metrics for {self.generator_name}",
boundaries=target_latency_histogram_buckets,
)
self.total_latency_histogram = metrics.Histogram(
f"{self.generator_name}_total_latency_ms",
f"Generator metrics for {self.generator_name}",
boundaries=target_latency_histogram_buckets,
)
def __call__(
self, async_generator_fn: Callable[..., AsyncGenerator[T, None]]
) -> Callable[..., AsyncGenerator[T, None]]:
async def new_gen(*args, **kwargs):
interval_clock = MsClock()
total_clock = MsClock()
is_first_token = True
try:
async for x in async_generator_fn(*args, **kwargs):
if is_first_token:
self.first_token_latency_histogram.observe(
total_clock.interval()
)
interval_clock.reset()
is_first_token = False
else:
self.token_latency_histogram.observe(
interval_clock.reset_interval()
)
yield x
finally:
self.total_latency_histogram.observe(total_clock.interval())
return new_gen
@@ -0,0 +1,353 @@
import hashlib
import random
import time
from enum import Enum
from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence
import ray
from ray import serve
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
from ray._common.usage.usage_lib import (
get_hardware_usages_to_report,
record_extra_usage_tag,
)
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.observability.telemetry_utils import DEFAULT_GPU_TYPE
from ray.llm._internal.common.utils.lora_utils import get_lora_model_ids
from ray.llm._internal.serve.observability.logging import get_logger
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
if TYPE_CHECKING:
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
LLM_SERVE_TELEMETRY_NAMESPACE = "llm_serve_telemetry"
LLM_SERVE_TELEMETRY_ACTOR_NAME = "llm_serve_telemetry"
logger = get_logger(__name__)
class TelemetryTags(str, Enum):
"""Telemetry tags for LLM SERVE."""
LLM_SERVE_SERVE_MULTIPLE_MODELS = "LLM_SERVE_SERVE_MULTIPLE_MODELS"
LLM_SERVE_SERVE_MULTIPLE_APPS = "LLM_SERVE_SERVE_MULTIPLE_APPS"
LLM_SERVE_LORA_BASE_MODELS = "LLM_SERVE_LORA_BASE_MODELS"
LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS = "LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS"
LLM_SERVE_AUTOSCALING_ENABLED_MODELS = "LLM_SERVE_AUTOSCALING_ENABLED_MODELS"
LLM_SERVE_AUTOSCALING_MIN_REPLICAS = "LLM_SERVE_AUTOSCALING_MIN_REPLICAS"
LLM_SERVE_AUTOSCALING_MAX_REPLICAS = "LLM_SERVE_AUTOSCALING_MAX_REPLICAS"
LLM_SERVE_TENSOR_PARALLEL_DEGREE = "LLM_SERVE_TENSOR_PARALLEL_DEGREE"
LLM_SERVE_NUM_REPLICAS = "LLM_SERVE_NUM_REPLICAS"
LLM_SERVE_MODELS = "LLM_SERVE_MODELS"
LLM_SERVE_GPU_TYPE = "LLM_SERVE_GPU_TYPE"
LLM_SERVE_NUM_GPUS = "LLM_SERVE_NUM_GPUS"
class TelemetryModel(BaseModelExtended):
"""Telemetry model for LLM Serve.
``model_id_hash`` is the dedup identity used by the telemetry agent and is
never recorded as a tag value. It is a hash of the model id so the raw model
name never reaches the head-node actor.
"""
model_id_hash: str
model_architecture: str
num_replicas: int
use_lora: bool
initial_num_lora_adapters: int
use_autoscaling: bool
min_replicas: int
max_replicas: int
tensor_parallel_degree: int
gpu_type: str
num_gpus: int
@ray.remote(
name=LLM_SERVE_TELEMETRY_ACTOR_NAME,
namespace=LLM_SERVE_TELEMETRY_NAMESPACE,
num_cpus=0,
lifetime="detached",
)
class TelemetryAgent:
"""Named Actor to keep the state of all deployed models and record telemetry."""
def __init__(self):
# Keyed by model_id_hash so repeated reports from replicas/restarts of
# the same model overwrite rather than accumulate.
self.models: Dict[str, TelemetryModel] = {}
self.record_tag_func = record_extra_usage_tag
def _update_record_tag_func(self, record_tag_func: Callable) -> None:
"""This method is only used in tests to record the telemetry tags to a different
object than Ray's default `record_extra_usage_tag` function."""
self.record_tag_func = record_tag_func
def _reset_models(self):
"""This method is only used in tests to clean up the models list."""
self.models = {}
def _multiple_models(self) -> str:
unique_models = {model.model_architecture for model in self.models.values()}
return "1" if len(unique_models) > 1 else "0"
@staticmethod
def _multiple_apps() -> str:
try:
try:
serve_status = serve.status()
except ray.exceptions.ActorDiedError:
# In a workspace with multiple Serve sessions, the long-running
# telemetry agent may still be connected to a previous, now-dead
# session. Shut down so we can reconnect to the live one.
serve.shutdown()
serve_status = serve.status()
return "1" if len(serve_status.applications) > 1 else "0"
except Exception:
# Telemetry must never fail; fall back to "not multiple".
logger.debug("Failed to query serve.status() for telemetry", exc_info=True)
return "0"
def _lora_base_nodes(self) -> str:
return ",".join(
[
model.model_architecture
for model in self.models.values()
if model.use_lora
]
)
def _lora_initial_num_adaptors(self) -> str:
return ",".join(
[
str(model.initial_num_lora_adapters)
for model in self.models.values()
if model.use_lora
]
)
def _autoscaling_enabled_models(self) -> str:
return ",".join(
[
model.model_architecture
for model in self.models.values()
if model.use_autoscaling
]
)
def _autoscaling_min_replicas(self) -> str:
return ",".join(
[
str(model.min_replicas)
for model in self.models.values()
if model.use_autoscaling
]
)
def _autoscaling_max_replicas(self) -> str:
return ",".join(
[
str(model.max_replicas)
for model in self.models.values()
if model.use_autoscaling
]
)
def _model_architectures(self) -> str:
return ",".join([model.model_architecture for model in self.models.values()])
def _tensor_parallel_degree(self) -> str:
return ",".join(
[str(model.tensor_parallel_degree) for model in self.models.values()]
)
def _num_replicas(self) -> str:
return ",".join([str(model.num_replicas) for model in self.models.values()])
def _gpu_type(self) -> str:
return ",".join([model.gpu_type for model in self.models.values()])
def _num_gpus(self) -> str:
return ",".join([str(model.num_gpus) for model in self.models.values()])
def generate_report(self) -> Dict[str, str]:
return {
TelemetryTags.LLM_SERVE_SERVE_MULTIPLE_MODELS: self._multiple_models(),
TelemetryTags.LLM_SERVE_SERVE_MULTIPLE_APPS: self._multiple_apps(),
TelemetryTags.LLM_SERVE_LORA_BASE_MODELS: self._lora_base_nodes(),
TelemetryTags.LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS: self._lora_initial_num_adaptors(),
TelemetryTags.LLM_SERVE_AUTOSCALING_ENABLED_MODELS: self._autoscaling_enabled_models(),
TelemetryTags.LLM_SERVE_AUTOSCALING_MIN_REPLICAS: self._autoscaling_min_replicas(),
TelemetryTags.LLM_SERVE_AUTOSCALING_MAX_REPLICAS: self._autoscaling_max_replicas(),
TelemetryTags.LLM_SERVE_MODELS: self._model_architectures(),
TelemetryTags.LLM_SERVE_TENSOR_PARALLEL_DEGREE: self._tensor_parallel_degree(),
TelemetryTags.LLM_SERVE_NUM_REPLICAS: self._num_replicas(),
TelemetryTags.LLM_SERVE_GPU_TYPE: self._gpu_type(),
TelemetryTags.LLM_SERVE_NUM_GPUS: self._num_gpus(),
}
def record(self, model: Optional[TelemetryModel] = None) -> None:
"""Record telemetry model."""
from ray._common.usage.usage_lib import TagKey
if model:
self.models[model.model_id_hash] = model
for key, value in self.generate_report().items():
try:
self.record_tag_func(TagKey.Value(key), value)
except ValueError:
# If the key doesn't exist in the TagKey enum, skip it.
continue
def _get_or_create_telemetry_agent() -> TelemetryAgent:
"""Get or create the detached telemetry agent.
``get_if_exists`` makes creation atomic, so concurrent replicas converge on a
single actor without racing on the name.
"""
return TelemetryAgent.options(
name=LLM_SERVE_TELEMETRY_ACTOR_NAME,
namespace=LLM_SERVE_TELEMETRY_NAMESPACE,
get_if_exists=True,
# Ensure the actor is created on the head node.
resources={HEAD_NODE_RESOURCE_NAME: 0.001},
# Ensure the actor is not scheduled with the existing placement group.
scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=None),
).remote()
def _retry_get_telemetry_agent(
max_retries: int = 5, base_delay: float = 0.1
) -> TelemetryAgent:
"""Get-or-create the telemetry agent, retrying transient failures."""
for attempt in range(max_retries):
try:
return _get_or_create_telemetry_agent()
except Exception as e:
logger.info(
"Attempt %s/%s to get telemetry agent failed", attempt + 1, max_retries
)
if attempt == max_retries - 1:
raise e
# Exponential backoff with jitter; ~3.5s total over 5 attempts.
time.sleep(base_delay * (2**attempt) + random.uniform(0, 0.5))
def _push_telemetry_report(model: Optional[TelemetryModel] = None) -> None:
"""Push telemetry report for a model."""
telemetry_agent = _retry_get_telemetry_agent()
assert telemetry_agent is not None
ray.get(telemetry_agent.record.remote(model))
class HardwareUsage:
"""Hardware usage class to report telemetry."""
def __init__(self, get_hardware_fn: Callable = get_hardware_usages_to_report):
self._get_hardware_fn = get_hardware_fn
def infer_gpu_from_hardware(self) -> str:
"""Infer the GPU type from the hardware when the accelerator type on llm config is
not specified.
"""
from ray.llm._internal.serve.core.configs.accelerators import AcceleratorType
all_accelerator_types = [t.value for t in AcceleratorType]
gcs_client = ray.experimental.internal_kv.internal_kv_get_gcs_client()
hardwares = self._get_hardware_fn(gcs_client)
for hardware in hardwares:
if hardware in all_accelerator_types:
return hardware
return DEFAULT_GPU_TYPE
def push_telemetry_report_for_all_models(
all_models: Optional[Sequence["LLMConfig"]] = None,
get_lora_model_func: Callable = get_lora_model_ids,
get_hardware_fn: Callable = get_hardware_usages_to_report,
):
"""Push a telemetry report for each model. Never raises."""
if not all_models:
return
for model in all_models:
# Telemetry must never break the caller (e.g. engine start).
try:
_push_model_telemetry(model, get_lora_model_func, get_hardware_fn)
except Exception:
logger.exception(
"Failed to push telemetry for model %s",
getattr(model, "model_id", "<unknown>"),
)
def _push_model_telemetry(
model: "LLMConfig",
get_lora_model_func: Callable,
get_hardware_fn: Callable,
) -> None:
use_lora = (
model.lora_config is not None
and model.lora_config.dynamic_lora_loading_path is not None
)
initial_num_lora_adapters = 0
if use_lora:
lora_model_ids = get_lora_model_func(
dynamic_lora_loading_path=model.lora_config.dynamic_lora_loading_path,
base_model_id=model.model_id,
)
initial_num_lora_adapters = len(lora_model_ids)
deployment_config = model.deployment_config
autoscaling_config = deployment_config.get("autoscaling_config")
if deployment_config.get("num_replicas") == "auto":
# "auto" resolves to an autoscaling config; mirror LLMConfig validation
# since the stored deployment_config keeps the literal "auto".
from ray.serve._private.config import handle_num_replicas_auto
_, autoscaling_config = handle_num_replicas_auto(
deployment_config.get("max_ongoing_requests"), autoscaling_config
)
use_autoscaling = autoscaling_config is not None
if use_autoscaling:
from ray.serve.config import AutoscalingConfig
if isinstance(autoscaling_config, dict):
autoscaling_config = AutoscalingConfig(**autoscaling_config)
num_replicas = (
autoscaling_config.initial_replicas or autoscaling_config.min_replicas
)
min_replicas = autoscaling_config.min_replicas
max_replicas = autoscaling_config.max_replicas
else:
# Fixed replica count; honor the configured value (including 0),
# defaulting to 1 only when unset.
num_replicas = deployment_config.get("num_replicas")
if num_replicas is None:
num_replicas = 1
min_replicas = max_replicas = num_replicas
engine_config = model.get_engine_config()
hardware_usage = HardwareUsage(get_hardware_fn)
telemetry_model = TelemetryModel(
# Hash so the cleartext model name (possibly proprietary) never reaches
# the head-node actor; deterministic across replicas/restarts so dedup holds.
model_id_hash=hashlib.sha256(model.model_id.encode("utf-8")).hexdigest(),
model_architecture=model.model_architecture,
num_replicas=num_replicas,
use_lora=use_lora,
initial_num_lora_adapters=initial_num_lora_adapters,
use_autoscaling=use_autoscaling,
min_replicas=min_replicas,
max_replicas=max_replicas,
tensor_parallel_degree=engine_config.tensor_parallel_degree,
gpu_type=model.accelerator_type or hardware_usage.infer_gpu_from_hardware(),
num_gpus=engine_config.num_devices,
)
_push_telemetry_report(telemetry_model)
@@ -0,0 +1,18 @@
# experimental_configs key overriding the per-node base port.
KV_EVENTS_PORT_BASE_KEY = "KV_EVENTS_PORT_BASE"
DEFAULT_KV_EVENTS_PORT_BASE = 5557
# experimental_configs key overriding the selection service's KV-indexer thread count.
KV_INDEXER_THREADS_KEY = "KV_INDEXER_THREADS"
DEFAULT_KV_INDEXER_THREADS = 4
# The engine's KV-event replay (ROUTER) socket sits this many ports above its PUB
# port, a separate range so it never collides with the PUB ports of colocated
# replicas (PORT_BASE + replica rank). Dynamo's selection service dials it to recover
# events missed before its SUB connected.
DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET = 1000
# TTL for a request's lifecycle tracking on the KV router actor. A live
# replica whose completion event was lost (e.g. a batch dropped on a
# transient actor outage) would otherwise leave its entry tracked forever.
REQUEST_TRACKING_TTL_S = 3600
@@ -0,0 +1,483 @@
import asyncio
import hashlib
import logging
import math
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, TypedDict
import ray
from ray import serve
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
DEFAULT_KV_INDEXER_THREADS,
REQUEST_TRACKING_TTL_S,
)
from ray.serve._private.common import DeploymentTargetInfo
from ray.serve._private.constants import (
SERVE_CONTROLLER_NAME,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
logger = logging.getLogger(SERVE_LOGGER_NAME)
KV_ROUTER_ACTOR_NAME = "serve_llm_kv_router"
# Dynamo's selection service keys all worker, indexer, and load state by
# (model_name, tenant_id). KVRouterActor is a deployment-scoped actor that
# instantiates a selection service and serves exactly one model, so a single
# fixed key scopes all of its workers together.
_MODEL_NAME = "default"
_TENANT_ID = "default"
# Hooks a replica may invoke through ``KVRouterActor.on_lifecycle_events``.
LIFECYCLE_HOOKS = frozenset(
{
"on_request_added",
"on_prefill_complete",
"on_decode_progress",
"on_request_completed",
}
)
def get_worker_id(replica_unique_id: str) -> int:
"""Deterministically derive a Dynamo worker id from a replica's unique id."""
return int.from_bytes(
hashlib.blake2b(replica_unique_id.encode(), digest_size=8).digest(), "big"
)
@dataclass
class RequestLifecycle:
"""In-flight request load state while the request is served by a replica."""
worker_id: int
prompt_tokens: int = 0
# Client-provided output-length estimate (``sampling_params.max_tokens``);
# weights each decode block's load by how much generation remains.
expected_output_tokens: Optional[int] = None
prefill_completed: bool = False
output_tokens: int = 0
# Running count of KV blocks (prompt + output) the request occupies; the
# cursor for booking each newly crossed decode block.
total_blocks: int = 0
# Monotonic admission time, for the TTL eviction sweep.
created_at: float = field(default_factory=time.monotonic)
class WorkerSelection(TypedDict):
"""The worker chosen by ``KVRouterActor.select_worker`` for a request."""
# The chosen worker.
worker_id: int
# Data-parallel rank within the worker.
dp_rank: int
# Matched prompt tokens available on the selected worker.
overlap_tokens: int
# Prompt tokens that still need prefill on the selected worker.
effective_prefill_tokens: int
class KVRouterActor:
"""Deployment-scoped Ray actor backing KV-aware routing.
Attached to the LLMServer deployment via Serve's ``DeploymentActorConfig``,
independent of any replica's lifetime.
1. Created once per deployment, attached to the LLMServer deployment via
Serve's ``DeploymentActorConfig`` (independent of any replica's lifetime).
2. Owns an in-process Dynamo ``SelectionService``.
3. Tracks live replicas via a ``LongPollClient`` on ``DEPLOYMENT_TARGETS``,
mapping each running replica to a Dynamo worker id.
4. The ``SelectionService`` maintains a global KV index radix tree, fed by
every replica's KV events; each node records which workers hold that KV block.
5. Scoring (``select_worker``) ranks candidate workers by KV-cache overlap
and prefill/decode load.
6. Books each request's lifecycle into the service's active-load tracker, so
in-flight load feeds back into scoring for subsequent requests.
"""
def __init__(self, indexer_threads: int = DEFAULT_KV_INDEXER_THREADS):
# KV-cache block size, learned once from the first replica's reported
# engine config and passed to the selection service, which uses it to
# track the worker's active load and index its KV blocks for overlap.
self._block_size: Optional[int] = None
self._indexer_threads = indexer_threads
# _replica_id_by_worker maps a Dynamo worker id to the running replica's full
# id string, kept in sync with the deployment's live replicas over LongPoll.
# NOTE (jeffreywang): _replica_id_by_worker is later used by select_worker
# to get candidate workers to route among.
self._replica_id_by_worker: Dict[int, str] = {}
# Per-request state that the lifecycle hooks need, keyed by request id, serves
# the following purposes:
# 1. Block cursor: Turn cumulative decode tokens into add_output_block deltas.
# 2. expected_output_tokens for decode-block decay weighting.
# 3. In-flight request set: Free reservation exactly once.
# Ordered oldest-first so the TTL sweep pops stale entries off the front.
self._requests: "OrderedDict[str, RequestLifecycle]" = OrderedDict()
# Reverse index of in-flight request ids per worker, kept in lockstep with
# _requests, so remove_worker is O(k) in the worker's requests, not O(N).
self._request_ids_by_worker: Dict[int, Set[str]] = {}
# Carries the effective prefill tokens select() computed at routing time to
# on_request_added, which books them via the explicit create_reservation.
# TODO(jeffreywang): this map is only needed because create_reservation
# requires the effective prefill tokens to be passed in explicitly. Once the
# selection service caches each select() result and create_reservation can
# look it up by request id, Ray no longer needs to forward it.
self._effective_prefill_tokens_by_request: Dict[str, int] = {}
self._pending_tasks: Set[asyncio.Task] = set()
self._long_poll_client: Optional[LongPollClient] = None
self._create_selection_service()
self._start_replica_tracking()
async def ready(self) -> None:
"""Readiness probe for KVAwareRouter to confirm KVRouterActor is initialized
before it starts routing requests to it.
"""
def get_block_size(self) -> int:
"""Return the KV-cache block size used for decode-block accounting."""
return self._block_size
def _create_selection_service(self) -> None:
"""Create the in-process Dynamo selection service for this deployment."""
# Imported here, not at module scope: Ray pickles this actor class by
# value, and Dynamo's pyo3 classes cannot be pickled as its globals.
try:
from dynamo.llm import SelectionService
except ImportError:
self._svc = None
logger.warning(
"ai-dynamo is not installed; KV-aware routing requires ai-dynamo."
)
return
self._svc = SelectionService(indexer_threads=self._indexer_threads)
logger.info(
"Dynamo SelectionService created (indexer threads %d).",
self._indexer_threads,
)
def _start_replica_tracking(self) -> None:
"""Subscribe to this deployment's running replicas via LongPollClient."""
deployment_id = serve.get_deployment_actor_context().deployment_id
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
self._long_poll_client = LongPollClient(
controller,
{
(
LongPollNamespace.DEPLOYMENT_TARGETS,
deployment_id,
): self._on_deployment_targets,
},
# Relies on KVRouterActor being an async actor (it defines async
# methods), so Ray runs __init__ inside the actor's event loop.
call_in_event_loop=asyncio.get_running_loop(),
client_id=f"{type(self).__name__}:{deployment_id}",
)
def _schedule(self, coro) -> None:
"""Run a coroutine on the actor's event loop, holding a reference until
it completes.
"""
task = asyncio.ensure_future(coro)
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
def _register_block_size(self, block_size: int, replica_id: str) -> None:
"""Pin the deployment's KV-cache block size from the first replica's
reported engine config.
"""
if self._block_size is None:
self._block_size = block_size
logger.info("KV router block size set to %d.", block_size)
elif block_size != self._block_size:
# Replicas of a deployment are expected to resolve the same block
# size, so a mismatch is unexpected. We still register the worker so
# the selection service spawns its KV-event listener, but the indexer
# only ingests blocks whose size matches the pinned block size, so a
# genuinely mismatched replica's KV events would be dropped (its KV
# cache never indexed).
logger.error(
"Replica %s reports KV block size %d but the KV router is "
"pinned at %d; registering it at the pinned size (replicas of a "
"deployment are expected to agree).",
replica_id,
block_size,
self._block_size,
)
def _on_deployment_targets(self, target_info: DeploymentTargetInfo) -> None:
"""LongPoll listener: reconcile tracked workers against the running-replica
snapshot.
Each replica advertises its KV-events endpoint via ``record_routing_stats``
(carried in ``RunningReplicaInfo.routing_stats``); newly advertised replicas
are registered with the selection service and departed ones evicted.
"""
members: Dict[int, tuple] = {}
for replica in target_info.running_replicas:
worker_id = get_worker_id(replica.replica_id.unique_id)
kv_event_metadata = replica.routing_stats.get("kv_event_metadata")
if kv_event_metadata is not None:
members[worker_id] = (
replica.replica_id.to_full_id_str(),
kv_event_metadata,
)
registered = set(self._replica_id_by_worker)
added = members.keys() - registered
removed = registered - members.keys()
for worker_id in removed:
self.remove_worker(worker_id)
self._replica_id_by_worker.pop(worker_id, None)
for worker_id in added:
replica_id, kv_event_metadata = members[worker_id]
self._register_block_size(kv_event_metadata["block_size"], replica_id)
self._replica_id_by_worker[worker_id] = replica_id
self._schedule(
self._upsert_worker(worker_id, replica_id, kv_event_metadata)
)
if added or removed:
logger.info(
"KV router replica membership updated: +%d -%d, tracking %d worker(s).",
len(added),
len(removed),
len(self._replica_id_by_worker),
)
def remove_worker(self, worker_id: int) -> None:
"""Evict a departed replica's worker and its KV blocks from the
selection service.
"""
# Drop the departed replica's in-flight requests; their completions can
# never arrive, so they would otherwise leak. delete_worker below frees
# their load in the service, so no per-request free_reservation is needed.
for request_id in self._request_ids_by_worker.pop(worker_id, set()):
self._requests.pop(request_id, None)
if self._svc is None:
return
self._schedule(self._svc.delete_worker(worker_id))
async def _upsert_worker(
self, worker_id: int, replica_id: str, kv_event_metadata: Dict[str, Any]
) -> None:
"""Register a replica's KV-event endpoint with the selection service.
The selection service spawns a connect-out ZMQ listener to the
replica's ``endpoint`` and indexes its live KV events.
"""
if self._svc is None:
return
dp_rank = kv_event_metadata["dp_rank"]
await self._svc.upsert_worker(
{
"worker_id": worker_id,
"model_name": _MODEL_NAME,
"tenant_id": _TENANT_ID,
# NOTE: SelectionService requires endpoint to be non-empty although it's left
# unused under an external runtime like Ray Serve LLM.
# TODO (jeffreywang): Allow empty endpoints upstream.
"endpoint": f"ray://{replica_id}",
"block_size": self._block_size,
# NOTE: max_num_batched_tokens is a proxy of load capacity for load-based
# scoring in the selection service.
"max_num_batched_tokens": kv_event_metadata["max_num_batched_tokens"],
"data_parallel_start_rank": dp_rank,
# TODO (jeffreywang): Support KV-aware routing for data parallel deployments.
"data_parallel_size": 1,
"kv_events_endpoints": {dp_rank: kv_event_metadata["endpoint"]},
# The listener dials this on a sequence gap (slow-joiner) to replay
# the events it missed before its SUB connected; without it those
# events are dropped and never indexed.
"replay_endpoint": kv_event_metadata.get("replay_endpoint"),
}
)
logger.info(
"Registered KV event worker %d for replica %s at %s.",
worker_id,
replica_id,
kv_event_metadata["endpoint"],
)
async def select_worker(
self,
request_id: str,
token_ids: List[int],
allowed_worker_ids: List[int],
) -> WorkerSelection:
"""Score the allowed workers for a request based on KV-cache overlap and
load and pick the best one.
Args:
request_id: Unique identifier for the request being routed.
token_ids: Prompt token ids used to compute KV-cache overlap.
allowed_worker_ids: Candidate worker ids the router may select from.
Returns:
The selected worker (see ``WorkerSelection``).
"""
if token_ids is None or len(token_ids) == 0:
raise ValueError("KV aware routing requires non-empty token_ids.")
if self._svc is None:
# ai-dynamo is not installed, so this deployment cannot score requests.
# Fail fast and surface RuntimeError to the client as a 503 via LLMRouter.
raise RuntimeError(
"KV-aware routing is unavailable because ai-dynamo is not "
"installed in the deployment's environment."
)
selection = await self._svc.select(
{
"model_name": _MODEL_NAME,
"tenant_id": _TENANT_ID,
"selection_id": request_id,
"token_ids": token_ids,
"allowed_worker_ids": allowed_worker_ids,
}
)
self._effective_prefill_tokens_by_request[request_id] = selection[
"effective_prefill_tokens"
]
return {
"worker_id": selection["worker_id"],
"dp_rank": selection["dp_rank"],
"overlap_tokens": selection["overlap"]["longest_matched"],
"effective_prefill_tokens": selection["effective_prefill_tokens"],
}
async def on_lifecycle_events(self, events: List[tuple]) -> None:
"""Apply a replica's ``(hook_name, args)`` lifecycle events in order.
The hooks are order-sensitive (e.g. a completion arriving before its
admission would resurrect an evicted request) so a replica sends its
events in submission order, batched into one call.
"""
if self._svc is None or self._block_size is None:
return
for hook_name, args in events:
if hook_name not in LIFECYCLE_HOOKS:
logger.warning("Ignoring unknown lifecycle hook %s", hook_name)
continue
try:
await getattr(self, hook_name)(*args)
except Exception:
# One hook raising must not abort the batch and drop other events.
logger.exception(
"KV lifecycle hook %s failed; skipping it and continuing.",
hook_name,
)
async def on_request_added(
self,
request_id: str,
worker_id: int,
token_ids: List[int],
expected_output_tokens: Optional[int] = None,
) -> None:
"""Admit a routed request into ``worker_id``'s active load, booking it
into the selection service which computes the worker's KV overlap from
``token_ids``, so the recorded prefill excludes the cached prefix."""
await self._evict_stale_requests()
prompt_tokens = len(token_ids)
self._requests[request_id] = RequestLifecycle(
worker_id=worker_id,
prompt_tokens=prompt_tokens,
expected_output_tokens=expected_output_tokens,
total_blocks=math.ceil(prompt_tokens / self._block_size),
)
self._request_ids_by_worker.setdefault(worker_id, set()).add(request_id)
effective_prefill_tokens = self._effective_prefill_tokens_by_request.pop(
request_id, None
)
await self._svc.create_reservation(
{
"model_name": _MODEL_NAME,
"tenant_id": _TENANT_ID,
"reservation_id": request_id,
"worker_id": worker_id,
"token_ids": token_ids,
"expected_output_tokens": expected_output_tokens,
"effective_prefill_tokens": effective_prefill_tokens,
}
)
if request_id not in self._requests:
await self._svc.free_reservation(request_id)
async def on_prefill_complete(self, request_id: str) -> None:
"""Record a request's prefill -> decode transition, dropping its prefill
load in the selection service."""
state = self._requests.get(request_id)
if state is None:
return
state.prefill_completed = True
await self._svc.prefill_complete(request_id)
async def on_decode_progress(
self, request_id: str, cumulative_output_tokens: int
) -> None:
"""Advance ``request_id`` to an exact cumulative output-token count,
booking one decode block in the selection service per crossed boundary.
"""
state = self._requests.get(request_id)
if state is None:
return
state.output_tokens = cumulative_output_tokens
new_total_blocks = math.ceil(
(state.prompt_tokens + cumulative_output_tokens) / self._block_size
)
decay_fraction = self._get_decay_fraction(state)
while new_total_blocks > state.total_blocks:
state.total_blocks += 1
self._svc.add_output_block(request_id, decay_fraction=decay_fraction)
async def on_request_completed(self, request_id: str) -> None:
"""Free ``request_id`` from the selection service's active load and the
local view."""
self._effective_prefill_tokens_by_request.pop(request_id, None)
state = self._requests.pop(request_id, None)
if state is not None:
self._untrack_worker_request(request_id, state.worker_id)
await self._svc.free_reservation(request_id)
def _untrack_worker_request(self, request_id: str, worker_id: int) -> None:
"""Drop a request from the per-worker reverse index, keeping it in
lockstep with ``_requests``."""
request_ids = self._request_ids_by_worker.get(worker_id)
if request_ids is not None:
request_ids.discard(request_id)
if not request_ids:
del self._request_ids_by_worker[worker_id]
async def _evict_stale_requests(self) -> None:
"""Backstop for a lost completion on a live replica: evict requests tracked
past ``REQUEST_TRACKING_TTL_S``, freeing their reservations.
"""
cutoff = time.monotonic() - REQUEST_TRACKING_TTL_S
while self._requests:
request_id, state = next(iter(self._requests.items()))
if state.created_at > cutoff:
break
self._requests.popitem(last=False)
self._untrack_worker_request(request_id, state.worker_id)
self._effective_prefill_tokens_by_request.pop(request_id, None)
logger.warning(
"Evicting stale KV request %s (tracked > %ds without completion); "
"freeing its reservation.",
request_id,
REQUEST_TRACKING_TTL_S,
)
await self._svc.free_reservation(request_id)
def _get_decay_fraction(self, state: RequestLifecycle) -> Optional[float]:
"""Fraction of output still expected, or ``None`` without an estimate;
weights each decode block by how much generation remains."""
if not state.expected_output_tokens:
return None
return max(0.0, 1.0 - state.output_tokens / state.expected_output_tokens)
@@ -0,0 +1,122 @@
import logging
import random
from typing import List, Optional
import ray
from ray.actor import ActorHandle
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.ingress.tokenizer import REQUEST_TOKEN_IDS_KWARG
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
KV_ROUTER_ACTOR_NAME,
get_worker_id,
)
from ray.serve._private.constants import (
SERVE_DEPLOYMENT_ACTOR_PREFIX,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.request_router.common import PendingRequest
from ray.serve._private.request_router.replica_wrapper import RunningReplica
from ray.serve._private.request_router.request_router import RequestRouter
from ray.serve.config import RequestRouterConfig
logger = logging.getLogger(SERVE_LOGGER_NAME)
class KVAwareRouter(RequestRouter):
"""Routes each request to the candidate that best balances expected KV-cache
overlap against the worker's current prefill/decode load.
Scoring is delegated to the deployment-scoped ``KVRouterActor`` (which owns the
Dynamo selection service and the global KV index); this per-handle router stays
thin and simply maps candidate replicas to/from Dynamo worker ids.
"""
def initialize_state(self):
"""Resolve the deployment's ``KVRouterActor``.
The actor is attached to this deployment via ``DeploymentActorConfig``
whenever the request router is a ``KVAwareRouter``, so it exists by the time
requests route. We resolve its Serve-generated name and block on a cheap
call to confirm it finished initializing, so the first routed request finds
a ready scorer.
"""
self._kv_router_actor = self._discover_kv_router_actor()
# Synchronization barrier: Ray defers actor methods until __init__ completes,
# so awaiting any method blocks until KVRouterActor is constructed.
ray.get(self._kv_router_actor.ready.remote())
def _discover_kv_router_actor(self) -> ActorHandle:
"""Handle to this deployment's ``KVRouterActor`` by its Serve-scoped name."""
prefix = (
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}"
f"{self._deployment_id.app_name}::{self._deployment_id.name}::"
)
suffix = f"::{KV_ROUTER_ACTOR_NAME}"
for entry in ray.util.list_named_actors(all_namespaces=True):
name = entry.get("name") or ""
if (
entry.get("namespace") == SERVE_NAMESPACE
and name.startswith(prefix)
and name.endswith(suffix)
):
return ray.get_actor(name, namespace=SERVE_NAMESPACE)
raise RuntimeError(
f"KVRouterActor for deployment {self._deployment_id} not found; it must "
"be attached via DeploymentActorConfig when using KVAwareRouter."
)
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> List[List[RunningReplica]]:
"""Choose the candidate replica(s) to route ``pending_request`` to.
Maps the candidate replicas to their Dynamo worker ids, asks the
``KVRouterActor`` to rank them via ``select_worker``, and routes to
the chosen worker's replica. With direct streaming enabled, HAProxy
then forwards the original request to that replica.
Requests with no prompt token ids have nothing to score on, so they route
to a random candidate. This covers the pre-routing ``/tokenize`` RPC (routed
before token ids exist) and token-less fallbacks (batch prompts,
truncated/unparseable bodies).
TODO (jeffreywang): Move pre-routing tokenization to KVRouterActor while
ensuring tokenization correctness.
Args:
candidate_replicas: The replicas eligible to serve the request.
pending_request: The request being routed.
Returns:
Ranked groups of replicas.
"""
token_ids = (
pending_request.kwargs.get(REQUEST_TOKEN_IDS_KWARG)
if pending_request is not None
else None
)
if not token_ids:
return [[random.choice(candidate_replicas)]] if candidate_replicas else []
worker_id_to_replica = {
get_worker_id(replica.replica_id.unique_id): replica
for replica in candidate_replicas
}
selection = await self._kv_router_actor.select_worker.remote(
pending_request.metadata.request_id,
token_ids,
list(worker_id_to_replica),
)
return [[worker_id_to_replica[selection["worker_id"]]]]
def is_kv_aware(llm_config: LLMConfig) -> bool:
"""Whether ``llm_config`` selects a ``KVAwareRouter`` for replica selection."""
request_router_config = llm_config.deployment_config.get("request_router_config")
if isinstance(request_router_config, dict):
request_router_config = RequestRouterConfig(**request_router_config)
return isinstance(request_router_config, RequestRouterConfig) and issubclass(
request_router_config.get_request_router_class(), KVAwareRouter
)
@@ -0,0 +1,66 @@
"""Helpers for wiring KV-aware routing into an LLM deployment."""
import logging
import ray
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
DEFAULT_KV_INDEXER_THREADS,
KV_INDEXER_THREADS_KEY,
)
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
KV_ROUTER_ACTOR_NAME,
KVRouterActor,
)
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 (
configure_kv_events_for_kv_routing,
)
from ray.serve._private.constants import SERVE_LOGGER_NAME
from ray.serve.config import DeploymentActorConfig
logger = logging.getLogger(SERVE_LOGGER_NAME)
def _maybe_setup_kv_aware_routing(
deployment_options: dict, llm_config: LLMConfig
) -> None:
"""Set up KV-aware routing when the deployment's request router is a
KVAwareRouter.
Attaches the KVRouterActor, which owns the deployment's global KV radix
tree, and enables the engine KV events that feed it.
"""
if not is_kv_aware(llm_config):
if llm_config.engine_kwargs.get("kv_events_config") is not None:
logger.warning(
"engine_kwargs['kv_events_config'] is set but the deployment's "
"request router is not a KVAwareRouter, so the engine's KV events "
"will not be consumed. To use them, configure KVAwareRouter via "
"deployment_config.request_router_config."
)
return
# Keep the engine's token-tracking gate which reads llm_config consistent
# with the router resolved here from the merged deployment options.
llm_config.deployment_config["request_router_config"] = deployment_options[
"request_router_config"
]
deployment_options["deployment_actors"] = [
*deployment_options.get("deployment_actors", []),
DeploymentActorConfig(
name=KV_ROUTER_ACTOR_NAME,
actor_class=ray.remote(KVRouterActor),
actor_options={"num_cpus": 0},
init_kwargs={
"indexer_threads": llm_config.experimental_configs.get(
KV_INDEXER_THREADS_KEY, DEFAULT_KV_INDEXER_THREADS
),
},
),
]
configure_kv_events_for_kv_routing(llm_config)
@@ -0,0 +1 @@
"""vLLM-specific KV-aware routing helpers."""
@@ -0,0 +1,163 @@
import logging
from typing import Any, Dict, Optional
import ray
from ray import serve
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
DEFAULT_KV_EVENTS_PORT_BASE,
DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET,
KV_EVENTS_PORT_BASE_KEY,
)
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_router import (
is_kv_aware,
)
from ray.serve._private.constants import SERVE_LOGGER_NAME
logger = logging.getLogger(SERVE_LOGGER_NAME)
def configure_kv_events_for_kv_routing(llm_config: LLMConfig) -> None:
"""
Enable engine KV-cache events for a KV-aware-routed deployment.
"""
engine_kwargs = llm_config.engine_kwargs
if engine_kwargs.get("enable_prefix_caching") is False:
logger.warning(
"KV-aware routing is configured but enable_prefix_caching is False; "
"the engine will not emit KV-cache events."
)
llm_config.update_engine_kwargs(
kv_events_config={
"enable_kv_cache_events": True,
"publisher": "zmq",
"endpoint": _default_kv_events_endpoint(llm_config),
# Bind a replay (ROUTER) socket so the selection service can recover
# the events it missed before its SUB connected (slow-joiner gap).
"replay_endpoint": _default_kv_events_replay_endpoint(llm_config),
}
)
_pin_block_hash_seed(llm_config)
def _pin_block_hash_seed(llm_config: LLMConfig) -> None:
"""Make engine block hashes content-deterministic across replicas.
The KV router's global indexer chains and dedups blocks by the engines'
block hashes, so identical content must hash identically on every
replica. vLLM salts its block-hash chain root per process unless
``PYTHONHASHSEED`` is set, so pin it deployment-wide.
"""
runtime_env = dict(llm_config.runtime_env or {})
env_vars = dict(runtime_env.get("env_vars") or {})
env_vars.setdefault("PYTHONHASHSEED", "0")
runtime_env["env_vars"] = env_vars
llm_config.runtime_env = runtime_env
def assign_replica_kv_events_endpoint(llm_config: LLMConfig) -> None:
"""Pin the engine's KV-events endpoint to a per-replica port.
Replicas of a deployment share one ``engine_kwargs``, so colocated
replicas would otherwise bind the same ZMQ port. Offsets the configured
base port by the replica's rank.
"""
if not is_kv_aware(llm_config):
return
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
if kv_events_config is None:
return
updated = dict(kv_events_config)
endpoint = updated.pop("endpoint")
replay_endpoint = updated.pop("replay_endpoint")
# With data parallelism the engine offsets ports by dp_rank under ZmqEventPublisher;
# otherwise offset by the replica's node-local rank so colocated replicas don't collide.
if llm_config.engine_kwargs.get("data_parallel_rank") is not None:
offset = 0
else:
offset = _get_replica_rank()
updated["endpoint"] = _get_offset_endpoint_port(endpoint, offset)
updated["replay_endpoint"] = _get_offset_endpoint_port(replay_endpoint, offset)
llm_config.update_engine_kwargs(kv_events_config=updated)
def resolve_kv_event_source_endpoint(llm_config: LLMConfig) -> Optional[str]:
"""This replica's node-routable KV-events endpoint, for the selection
service to connect out to.
The engine's KV-events endpoint at the replica's node IP; ``None`` when
KV-cache events are not enabled.
"""
if not is_kv_aware(llm_config):
return None
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
if kv_events_config is None:
return None
return _get_node_routable_endpoint(llm_config, kv_events_config["endpoint"])
def get_kv_event_routing_stats(
llm_config: LLMConfig, block_size: int, max_num_batched_tokens: int
) -> Dict[str, Any]:
"""Returns this replica's routing-stats payload advertising its KV-events
endpoint and the engine's resolved KV-cache block size."""
if not is_kv_aware(llm_config):
return {}
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
if kv_events_config is None:
return {}
kv_event_metadata = {
"endpoint": _get_node_routable_endpoint(
llm_config, kv_events_config["endpoint"]
),
"block_size": block_size,
"max_num_batched_tokens": max_num_batched_tokens,
"dp_rank": llm_config.engine_kwargs.get("data_parallel_rank") or 0,
"replay_endpoint": _get_node_routable_endpoint(
llm_config, kv_events_config["replay_endpoint"]
),
}
return {"kv_event_metadata": kv_event_metadata}
def _get_node_routable_endpoint(llm_config: LLMConfig, endpoint: str) -> str:
"""Rewrite a wildcard-bound engine endpoint to this replica's node IP.
Offsets by ``data_parallel_rank`` to match the engine's own per-rank offset,
so the selection service dials the right socket.
"""
dp_rank = llm_config.engine_kwargs.get("data_parallel_rank")
if dp_rank is not None:
endpoint = _get_offset_endpoint_port(endpoint, dp_rank)
port = endpoint.rsplit(":", 1)[1]
return f"tcp://{ray.util.get_node_ip_address()}:{port}"
def _get_replica_rank() -> int:
return serve.get_replica_context().rank.local_rank
def _default_kv_events_endpoint(llm_config: LLMConfig) -> str:
port_base = int(
llm_config.experimental_configs.get(
KV_EVENTS_PORT_BASE_KEY, DEFAULT_KV_EVENTS_PORT_BASE
)
)
return f"tcp://*:{port_base}"
def _default_kv_events_replay_endpoint(llm_config: LLMConfig) -> str:
port_base = int(
llm_config.experimental_configs.get(
KV_EVENTS_PORT_BASE_KEY, DEFAULT_KV_EVENTS_PORT_BASE
)
)
return f"tcp://*:{port_base + DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET}"
def _get_offset_endpoint_port(endpoint: str, offset: int) -> str:
"""Offset a TCP endpoint's port with vLLM's ZmqEventPublisher convention."""
base, port = endpoint.rsplit(":", 1)
return f"{base}:{int(port) + offset}"
@@ -0,0 +1,192 @@
import asyncio
from typing import Any, List, Optional, Type
from vllm.outputs import RequestOutput
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine.async_llm import AsyncLLM
from ray import serve
from ray.actor import ActorHandle
from ray.exceptions import RayActorError, RayTaskError
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
KV_ROUTER_ACTOR_NAME,
get_worker_id,
)
from ray.llm._internal.serve.utils.server_utils import get_serve_request_id
logger = get_logger(__name__)
def _get_prompt_token_ids(prompt: Any) -> List[int]:
"""The prompt's pre-tokenized token ids."""
try:
return list(prompt["prompt_token_ids"])
except (KeyError, TypeError) as e:
raise ValueError(
"KV-aware token tracking requires a pre-tokenized prompt "
f"(dict with 'prompt_token_ids'); got {type(prompt).__name__}"
) from e
class LifecycleEventForwarder:
"""Ordered, non-blocking bridge from the engine to the KV router actor
which maintains per-replica token load statistics.
``report`` only enqueues locally, so generation never blocks on the actor.
A single delivery task per replica drains the queue to the actor, awaiting
one ``on_lifecycle_events`` call at a time so events arrive in the order they
were reported. Events that pile up during a call are sent together in the
next one.
"""
def __init__(self, actor: ActorHandle, worker_id: int):
self.actor = actor
self.worker_id = worker_id
self._events: asyncio.Queue = asyncio.Queue()
self._delivery_task: Optional[asyncio.Task] = None
def report(self, method_name: str, *args) -> None:
if self._delivery_task is None or self._delivery_task.done():
self._delivery_task = asyncio.get_running_loop().create_task(
self._deliver()
)
self._events.put_nowait((method_name, args))
async def _deliver(self) -> None:
while True:
# Wait for the next event, then drain whatever queued up behind it
# into the same batch.
batch = [await self._events.get()]
while not self._events.empty():
batch.append(self._events.get_nowait())
try:
await self.actor.on_lifecycle_events.remote(batch)
except (RayActorError, RayTaskError) as e:
logger.warning("Dropping KV lifecycle events: %s", e)
finally:
for _ in batch:
self._events.task_done()
async def flush(self) -> None:
"""Wait until every reported event has been delivered."""
await self._events.join()
def close(self) -> None:
"""Cancel the delivery task on engine shutdown."""
if self._delivery_task is not None:
self._delivery_task.cancel()
self._delivery_task = None
class RequestTokenTracker:
"""Drives the request lifecycle hooks for one ``generate()`` stream."""
def __init__(
self,
forwarder: LifecycleEventForwarder,
request_id: str,
prompt_token_ids: List[int],
expected_output_tokens: Optional[int],
):
self._forwarder = forwarder
self._request_id = request_id
self._cumulative = 0
self._prefill_marked = False
self._finished = False
forwarder.report(
"on_request_added",
request_id,
forwarder.worker_id,
prompt_token_ids,
expected_output_tokens,
)
def on_output(self, output: RequestOutput) -> None:
"""Observe one engine ``RequestOutput`` (forwarded to the caller as-is).
vLLM streams either DELTA chunks or a single FINAL_ONLY chunk; both
carry only new tokens, so output progress simply accumulates.
"""
step_tokens = sum(len(o.token_ids or []) for o in output.outputs)
if step_tokens == 0:
# No new tokens this step (e.g. a finish-only chunk).
return
self._cumulative += step_tokens
if not self._prefill_marked:
# The first output token signals prefill completion.
self._prefill_marked = True
self._forwarder.report("on_prefill_complete", self._request_id)
self._forwarder.report("on_decode_progress", self._request_id, self._cumulative)
def finish(self) -> None:
"""Report completion exactly once."""
if not self._finished:
self._finished = True
self._forwarder.report("on_request_completed", self._request_id)
def enable_token_tracking(engine_cls: Type[AsyncLLM]) -> Type[AsyncLLM]:
"""Decorator adding KV-router request lifecycle tracking."""
class TokenTrackingEngine(engine_cls):
_lifecycle_forwarder: Optional[LifecycleEventForwarder] = None
_resolve_warned: bool = False
def _resolve_lifecycle_forwarder(self) -> Optional[LifecycleEventForwarder]:
if self._lifecycle_forwarder is None:
try:
actor = serve.get_deployment_actor(KV_ROUTER_ACTOR_NAME)
worker_id = get_worker_id(
serve.get_replica_context().replica_id.unique_id
)
self._lifecycle_forwarder = LifecycleEventForwarder(
actor, worker_id
)
except Exception as e:
# Warn once: resolution is retried per request until it succeeds.
if not self._resolve_warned:
self._resolve_warned = True
logger.warning("KV token tracking disabled: %s", e)
return self._lifecycle_forwarder
def shutdown(self, *args, **kwargs):
if self._lifecycle_forwarder is not None:
self._lifecycle_forwarder.close()
return super().shutdown(*args, **kwargs)
async def generate(self, prompt, sampling_params, request_id, *args, **kwargs):
stream = super().generate(
prompt, sampling_params, request_id, *args, **kwargs
)
forwarder = self._resolve_lifecycle_forwarder()
# CUMULATIVE repeats output-so-far per chunk; our accounting sums
# deltas, so skip it rather than over-count. vLLM's OpenAI layer only
# uses DELTA/FINAL_ONLY (*Request.to_sampling_params):
# https://github.com/vllm-project/vllm/tree/main/vllm/entrypoints/openai
if forwarder is None or (
sampling_params.output_kind == RequestOutputKind.CUMULATIVE
):
async for output in stream:
yield output
return
lifecycle_request_id = get_serve_request_id() or request_id
tracker = RequestTokenTracker(
forwarder,
lifecycle_request_id,
_get_prompt_token_ids(prompt),
# The request's own output cap is its expected length; weights
# the selection service's decode-block decay.
# TODO(jeffreywang): Use an agent-provided expected-OSL hint for
# more accurate decode-load estimation.
sampling_params.max_tokens,
)
try:
async for output in stream:
tracker.on_output(output)
yield output
finally:
tracker.finish()
return TokenTrackingEngine
@@ -0,0 +1,420 @@
# These imports are used for metrics tracking, will remove for PR
import logging
import time
from typing import (
Any,
List,
Optional,
)
import ray
from ray.actor import ActorHandle
from ray.llm._internal.serve.routing_policies.prefix_aware.prefix_tree import (
PrefixTreeActor,
)
from ray.serve._private.common import ReplicaID
from ray.serve._private.constants import (
SERVE_CONTROLLER_NAME,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.replica_result import ReplicaResult
from ray.serve._private.request_router import (
PowerOfTwoChoicesRequestRouter,
)
from ray.serve._private.request_router.common import (
PendingRequest,
)
from ray.serve._private.request_router.replica_wrapper import (
RunningReplica,
)
from ray.serve._private.request_router.request_router import (
LocalityMixin,
MultiplexMixin,
RequestRouter,
)
logger = logging.getLogger(SERVE_LOGGER_NAME)
class PrefixCacheAffinityRouter(LocalityMixin, MultiplexMixin, RequestRouter):
"""Extends the PowerOfTwoChoicesRequestRouter with prefix-matching capabilities.
This request router optimizes replica selection by considering input text prefixes:
1. Mixes between three strategies to balance prefix cache hit rate and load balancing:
- When load is balanced (queue length difference < threshold), it selects replicas
with the highest prefix match rate for the input text
- When load is balanced but match rate is below 10%, it falls back to the smallest tenants
- When load is imbalanced, it uses the default Power of Two selection
2. Maintains a prefix tree to track which replicas have processed similar inputs:
- Inserts prompt text into the prefix tree after routing
- Uses this history to inform future routing decisions
This approach improves performance by routing related requests to the same replicas,
increasing cache locality and reducing overhead for language model inference.
"""
def initialize_state(
self,
imbalanced_threshold: Optional[float] = float("inf"),
match_rate_threshold: Optional[float] = 0.1,
do_eviction: Optional[bool] = False,
eviction_threshold_chars: Optional[int] = 400_000,
eviction_target_chars: Optional[int] = 360_000,
eviction_interval_secs: Optional[int] = 10,
tree_actor: Optional[ActorHandle] = None,
):
"""Initialize the prefix-aware routing state and configuration.
Args:
imbalanced_threshold: Threshold for queue length difference to consider
load balanced. When the difference between replica queue lengths is
less than this value, prefix-aware routing is used.
match_rate_threshold: Minimum prefix match rate (0.0-1.0) required to
use prefix-aware routing. If match rate is below this threshold,
falls back to smallest tenant selection.
do_eviction: Whether to enable automatic eviction of old prefix tree
entries to manage memory usage.
eviction_threshold_chars: Maximum number of characters in the prefix
tree before eviction is triggered.
eviction_target_chars: Target number of characters to reduce the
prefix tree to during eviction.
eviction_interval_secs: Interval in seconds between eviction checks
when eviction is enabled.
tree_actor: The actor to use for the prefix tree in a test environment.
If None, a detached actor will be created/retrieved.
"""
# === Prefix-aware routing logic hyperparameters ===
self._imbalanced_threshold = imbalanced_threshold
self._match_rate_threshold = match_rate_threshold
# === Eviction policy ===
self._do_eviction = do_eviction
self._eviction_loop_running = False
self._eviction_threshold_chars = eviction_threshold_chars
# Default eviction_target_chars to eviction_threshold_chars if not specified
self._eviction_target_chars = (
eviction_target_chars
if eviction_target_chars is not None
else eviction_threshold_chars
)
self._eviction_interval_secs = eviction_interval_secs
if tree_actor is None:
# Create deployment-specific detached actor to avoid replica ID conflicts
# in multi-deployment scenarios (e.g., PD disaggregation with DP)
deployment_name = self._deployment_id.name if self._deployment_id else None
app_name = self._deployment_id.app_name if self._deployment_id else None
actor_name = "LlmPrefixTreeActor"
actor_namespace_components = [SERVE_NAMESPACE]
if app_name:
actor_namespace_components.append(app_name)
if deployment_name:
actor_namespace_components.append(deployment_name)
actor_namespace = "::".join(actor_namespace_components)
self._tree_actor = PrefixTreeActor.options(
name=actor_name,
namespace=actor_namespace,
get_if_exists=True,
lifetime="detached",
).remote()
# Register the actor with the controller for cleanup on serve.shutdown()
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
ray.get(
controller._register_shutdown_cleanup_actor.remote(self._tree_actor)
)
else:
self._tree_actor = tree_actor
def _extract_text_from_request(self, pending_request: PendingRequest) -> str:
"""Extracts the text content from a pending request for prefix matching.
Searches through request arguments for either 'messages' or 'prompt' attributes,
then normalizes the content to a single string representation that can be used
for prefix tree operations.
Args:
pending_request: The request to extract text from
Returns:
A string containing the prompt text or concatenated message contents
Raises:
ValueError: If no prompt or messages attribute is found in the request
"""
prompt = None
for arg in pending_request.args:
valid_input_types = ["messages", "prompt"]
for valid_input_type in valid_input_types:
if hasattr(arg, valid_input_type):
prompt = (
arg.prompt if valid_input_type == "prompt" else arg.messages
)
break
if prompt is not None:
break
if prompt is None:
raise ValueError(
"No request with message or prompt attribute found in pending_request.args"
)
return self._normalize_prompt_to_string(prompt)
def _coerce_to_text(self, value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(self._coerce_to_text(item) for item in value)
if isinstance(value, dict):
text_value = value.get("text")
if isinstance(text_value, str):
return text_value
if "content" in value:
return self._coerce_to_text(value["content"])
return ""
def _normalize_prompt_to_string(self, prompt: Any) -> str:
"""Normalize prompt/messages a single string of characters.
This is not exhaustive (e.g. thinking parts, multimodal are not supported).
TODO(seiji): find a more maintainable way to normalize the prompt/messages.
Supported:
- string → return as-is
- list of strings → concat
- list of message dicts with 'content' as string → concat
- list of message dicts with 'content' as list of dicts → concat the 'text' fields from those parts
"""
if isinstance(prompt, str):
return prompt
if isinstance(prompt, list):
return "".join(
self._coerce_to_text(
message.get("content") if isinstance(message, dict) else message
)
for message in prompt
)
return ""
async def _prefix_match_best_replicas(
self,
pending_request: Optional[PendingRequest],
candidate_replicas: List[RunningReplica],
) -> List[RunningReplica]:
"""
Returns a set of candidate replicas, of which the one with the smallest replica queue will be chosen.
0. Default: same as pow 2 request router, return 2 replicas at random.
1. If load is balanced, choose replica(s) with highest prefix match rate. If highest hit rate is below 10% or no match found, use replicas with smallest KV cache usage.
2. If load is imbalanced, use default.
"""
chosen_replica_id_strings = []
if (
pending_request is not None
and pending_request.args is not None
and len(pending_request.args) > 0
):
input_text = self._extract_text_from_request(pending_request)
if input_text is not None:
# Start Sphinx tag: __begin_load_balance_component__
# Check for imbalanced load.
highest_queue_len = 0
lowest_queue_len = float("inf")
not_in_cache: List[ReplicaID] = []
if self._use_replica_queue_len_cache:
# Populate available queue lens from the cache.
for r in candidate_replicas:
queue_len = self._replica_queue_len_cache.get(r.replica_id)
if queue_len is None or queue_len >= r.max_ongoing_requests:
not_in_cache.append(r)
else:
highest_queue_len = max(highest_queue_len, queue_len)
lowest_queue_len = min(lowest_queue_len, queue_len)
else:
not_in_cache = candidate_replicas
if len(not_in_cache) > 0:
for r, queue_len in await self._probe_queue_lens(
not_in_cache,
0,
):
if queue_len is None:
continue
highest_queue_len = max(highest_queue_len, queue_len)
lowest_queue_len = min(lowest_queue_len, queue_len)
is_imbalanced = (
highest_queue_len - lowest_queue_len > self._imbalanced_threshold
)
# End Sphinx tag: __end_load_balance_component__
# Start Sphinx tag: __begin_prefix_match_component__
if not is_imbalanced:
# Convert candidate replica IDs to strings for prefix matching.
candidate_replica_ids_strings = [
r.replica_id.to_full_id_str() for r in candidate_replicas
]
(matched_text, matched_tenant_id_strings,) = ray.get(
self._tree_actor.prefix_match.remote(
input_text, candidate_replica_ids_strings
)
)
match_rate = len(matched_text) / len(input_text)
if match_rate < self._match_rate_threshold:
smallest_tenants_id_strings = ray.get(
self._tree_actor.get_smallest_tenants.remote()
)
if (
smallest_tenants_id_strings is not None
and len(smallest_tenants_id_strings) > 0
):
chosen_replica_id_strings = smallest_tenants_id_strings
else:
if (
matched_tenant_id_strings is not None
and len(matched_tenant_id_strings) > 0
):
chosen_replica_id_strings = matched_tenant_id_strings
# End Sphinx tag: __end_prefix_match_component__
return [
[
self._replicas[ReplicaID.from_full_id_str(chosen_id_string)]
for chosen_id_string in chosen_replica_id_strings
]
]
# Start Sphinx tag: __begin_on_replica_actor_died__
def on_replica_actor_died(self, replica_id: ReplicaID):
"""Drop replica from replica set so it's not considered for future requests."""
super().on_replica_actor_died(replica_id)
ray.get(self._tree_actor.remove_tenants.remote([replica_id.to_full_id_str()]))
# End Sphinx tag: __end_on_replica_actor_died__
def update_replicas(self, replicas: List[RunningReplica]):
"""Update the set of available replicas to be considered for routing.
When the set of replicas changes, we may spawn additional routing tasks
if there are pending requests.
"""
# 1) Record the old replica IDs
old_ids = set(self._replica_id_set)
# 2) Run the default update_replicas logic
super().update_replicas(replicas)
# 3) Figure out which replicas were added / removed
new_ids = set(self._replica_id_set)
added = new_ids - old_ids
removed = old_ids - new_ids
# 4) Update the prefix tree with the changes
if added:
added_strings = [rid.to_full_id_str() for rid in added]
ray.get(self._tree_actor.add_tenants.remote(added_strings, time.time()))
if removed:
removed_strings = [rid.to_full_id_str() for rid in removed]
ray.get(self._tree_actor.remove_tenants.remote(removed_strings))
# === Start tasks (if enabled and not already running) ===
if self._do_eviction and not self._eviction_loop_running:
ray.get(
self._tree_actor.start_eviction_loop.remote(
self._eviction_threshold_chars,
self._eviction_target_chars,
self._eviction_interval_secs,
)
)
self._eviction_loop_running = True
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> List[RunningReplica]:
"""One iteration of the power of two choices procedure that chooses
(at most) two random available replicas.
For multiplexing, this will first attempt to choose replicas that have the
requested model ID for a configured timeout. If no replicas with the matching
model ID are available after that timeout, it will fall back to the regular
procedure.
"""
# Start Sphinx tag: __begin_pow2_router_base__
# Get fallback replicas from PowerOfTwoChoicesRequestRouter
fallback_replicas = await PowerOfTwoChoicesRequestRouter.choose_replicas(
self,
candidate_replicas=candidate_replicas,
pending_request=pending_request,
)
if pending_request is None or not fallback_replicas:
return fallback_replicas
# End Sphinx tag: __end_pow2_router_base__
if (
pending_request is not None
and pending_request.metadata.multiplexed_model_id
):
# Get candidates for multiplexed model ID.
candidate_replica_ids = self.apply_multiplex_routing(
pending_request=pending_request,
)
else:
# Get candidates for locality preference.
candidate_replica_ids = self.apply_locality_routing(
pending_request=pending_request,
)
if not candidate_replica_ids:
return fallback_replicas
# Convert candidate replica IDs to RunningReplica objects.
replica_id_to_replica_map = {
replica.replica_id: replica for replica in candidate_replicas
}
candidate_replicas = [
replica_id_to_replica_map[candidate_replica_id]
for candidate_replica_id in candidate_replica_ids
]
chosen_replicas = await self._prefix_match_best_replicas(
pending_request, candidate_replicas
)
if chosen_replicas[0]:
return chosen_replicas
return fallback_replicas
# Start Sphinx tag: __begin_on_request_routed__
def on_request_routed(
self,
pending_request: PendingRequest,
replica_id: ReplicaID,
result: ReplicaResult,
):
"""Called when a request is routed to a replica.
This is used as a callback to update the state of the request router
after a response is generated.
"""
# Right now this only inserts the prompt into the prefix tree, not the response (streaming response makes things complicated)
if (
pending_request is not None
and pending_request.args is not None
and len(pending_request.args) > 0
):
input_text = self._extract_text_from_request(pending_request)
if input_text is not None:
# Insert into prefix tree
ray.get(
self._tree_actor.insert.remote(
input_text, replica_id.to_full_id_str(), time.time()
)
)
# End Sphinx tag: __end_on_request_routed__
@@ -0,0 +1,620 @@
from __future__ import annotations
import logging
import os
import threading
from threading import RLock
from typing import Any, Dict, List, Optional, Tuple
import ray
from ray.serve._private.constants import (
SERVE_LOGGER_NAME,
)
logger = logging.getLogger(SERVE_LOGGER_NAME)
class Node:
"""
Node in a prefix tree that represents a segment of text and can belong to multiple tenants.
Each node also tracks the last access time for each tenant.
Simple example of root node connected to two children Nodes:
root = Node(text="", parent=None, edge_label_to_child={"f": fooNode, "b": barNode}, tenant_to_last_access_time={"tenant_1": 2})
fooNode = Node(text="foo", parent=root, edge_label_to_child={}, tenant_to_last_access_time={"tenant_1": 1})
barNode = Node(text="bar", parent=root, edge_label_to_child={}, tenant_to_last_access_time={"tenant_1": 2})
In the above example, "foo" was inserted at time 1, and "bar" was inserted at time 2.
It follows that root was last accessed at time 2.
"""
def __init__(self, text: str = "", parent: Optional[Node] = None) -> None:
"""
Initialize a node in the prefix tree.
Args:
text: The text segment this node represents
parent: The parent node of this node
"""
self.text: str = text
self.parent: Optional[Node] = parent
# Maps first character to child node
self.edge_label_to_child: Dict[str, Node] = {}
# For each tenant that has inserted text matching this node, track its last access timestamp (in seconds)
self.tenant_to_last_access_time: Dict[str, float] = {}
# Doubly linked list pointers for LRU tracking per tenant
# Points to the less recently used node (toward tail)
self.tenant_to_older_node: Dict[str, Optional[Node]] = {}
# Points to the more recently used node (toward head)
self.tenant_to_newer_node: Dict[str, Optional[Node]] = {}
class PrefixTree:
"""
Thread-safe multi-tenant prefix tree (approximate radix tree).
Features:
1. Stores data for multiple tenants in the same tree structure
2. Thread-safe with node-level locking for concurrent access
3. LRU eviction based on tenant access time
4. Efficient prefix matching across multiple tenants
Example tree structure:
Representing the strings inserted in order:
- "helloworld" at time 1 by tenant_1
- "hellothere" at time 2 by tenant_2
- "hellothomas" at time 3 by tenant_2
root: [] {tenant_1: 1, tenant_2: 3}
(h) → [hello] {tenant_1: 1, tenant_2: 3}
(w) → [world] {tenant_1: 1}
(t) → [th] {tenant_2: 3}
(e) → [ere] {tenant_2: 2}
(o) → [omas] {tenant_2: 3}
Legend for each node:
- [text] = Node.text
- {tenant, timestamp} = Node.tenant_to_last_access_time
- (x) = edge label (first character used as key for parent's children)
PrefixTree instance variables:
self.tenant_to_char_count = {"tenant_1": 10, "tenant_2": 14}
self.tenant_to_lru_tail = {"tenant_1": Node("world"), "tenant_2": Node("ere")}
"""
def __init__(self) -> None:
"""Initialize an empty prefix tree."""
self.lock: RLock = RLock()
# Root is always the head of the LRU list for each tenant.
self.root: Node = Node()
# Tracks total character count per tenant. Can be used by the replica request router to determine which tenant to evict, and by how much.
# Also uses the keys to track the active tenants in the tree.
self.tenant_to_char_count: Dict[str, int] = {}
# LRU tracking - root is always the head, tail is the least recently used.
self.tenant_to_lru_tail: Dict[str, Optional[Node]] = {}
self._eviction_thread: Optional[threading.Thread] = None
self._eviction_stop_event: threading.Event = threading.Event()
@staticmethod
def _shared_prefix_count(a: str, b: str) -> int:
"""
Count the number of shared characters at the beginning of two strings.
Args:
a: First string
b: Second string
Returns:
Number of matching characters at the beginning
"""
return len(os.path.commonprefix([a, b]))
def _get_lru_chain(self, tenant: str) -> List[Node]:
"""
Get the LRU chain for a given tenant by traversing from the root to the oldest node.
Note: This method is intended to be used only in tests.
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
return []
nodes = []
current_node = self.root
while current_node:
nodes.append(current_node)
current_node = current_node.tenant_to_older_node.get(tenant)
return nodes
def _insert_node_into_linked_list(
self,
node: Node,
newer_neighbor: Optional[Node],
older_neighbor: Optional[Node],
tenant: str,
) -> None:
"""
Insert a node into the LRU list between two neighbors. Updates the neighbors' pointers and the tail pointer, if that changes.
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
return
# Skip if node is the root
if node == self.root:
return
node.tenant_to_newer_node[tenant] = newer_neighbor
node.tenant_to_older_node[tenant] = older_neighbor
if newer_neighbor:
newer_neighbor.tenant_to_older_node[tenant] = node
if older_neighbor:
older_neighbor.tenant_to_newer_node[tenant] = node
if self.tenant_to_lru_tail[tenant] == newer_neighbor:
self.tenant_to_lru_tail[tenant] = node
def _remove_node_from_linked_list(self, node: Node, tenant: str) -> None:
"""
Remove a node from the LRU list. Updates the neighbors' pointers and the tail pointer, if that changes.
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
return
# Skip if node is the root
if node == self.root:
return
# Connect older and newer neighbors
older_neighbor = node.tenant_to_older_node.get(tenant)
newer_neighbor = node.tenant_to_newer_node.get(tenant)
if older_neighbor:
older_neighbor.tenant_to_newer_node[tenant] = newer_neighbor
if newer_neighbor:
newer_neighbor.tenant_to_older_node[tenant] = older_neighbor
# Update tail pointer if necessary
if self.tenant_to_lru_tail[tenant] == node:
self.tenant_to_lru_tail[tenant] = newer_neighbor
# Remove node from list
node.tenant_to_newer_node.pop(tenant, None)
node.tenant_to_older_node.pop(tenant, None)
def _remove_tenant_single_node(self, tenant: str, node: Node) -> int:
"""
Remove a tenant from a single node.
Args:
tenant: Tenant to remove
node: Node to remove tenant from
Returns:
Number of characters removed (0 if preconditions not met)
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
return 0
if tenant not in node.tenant_to_last_access_time:
logger.debug(
f"Tenant '{tenant}' does not have node '{node.text}'. No action taken."
)
return 0
removed_chars_len: int = len(node.text)
self.tenant_to_char_count[tenant] -= removed_chars_len
node.tenant_to_last_access_time.pop(tenant, None)
self._remove_node_from_linked_list(node, tenant)
# Clean up empty nodes
if not node.tenant_to_last_access_time and node.parent:
if (
node.text and node.text[0] in node.parent.edge_label_to_child
): # Defensive check
node.parent.edge_label_to_child.pop(node.text[0], None)
return removed_chars_len
def add_tenants(self, tenants: List[str], time_s: float) -> None:
"""
Add multiple new tenants to the tree. Also inserts an empty string for each tenant into the tree.
For each tenant that already exists, a warning is logged and that tenant is skipped.
Args:
tenants: List of tenants to add
time_s: Current timestamp in seconds
"""
with self.lock:
for tenant in tenants:
if tenant in self.tenant_to_char_count:
logger.debug(f"Tenant '{tenant}' already exists. Skipping.")
continue
self.tenant_to_char_count[tenant] = 0
self.tenant_to_lru_tail[tenant] = self.root
# Initialize the root node as the head of the LRU list for this tenant
self.root.tenant_to_newer_node[tenant] = None
self.root.tenant_to_older_node[tenant] = None
self.insert("", tenant, time_s)
def insert(self, text: str, tenant: str, time_s: float) -> None:
"""
Insert text into tree for a specific tenant, but only if the tenant already exists.
If the tenant doesn't exist in the tree, this will log a warning and return without
inserting anything. Use add_tenants() first to add a new tenant.
Args:
text: Text to insert
tenant: Tenant
time_s: Current timestamp in seconds
Loop structure:
1. We update the current node at the start of each iteration of the while loop.
This includes updating tenant_to_char_count and tenant_to_last_access_time, and moving the node to the front of the LRU list.
2. Each iteration then either:
a. Breaks (if we've processed the entire string).
b. Processes the next segment of text by:
1. If no child exists for the first character, create a new leaf node that matches the current text.
2. Then, match the current text with the child's text:
a. If they share a prefix (partial match), split the node and traverse into the new parent.
b. If they fully match, traverse into the child node.
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
logger.debug(
f"Tenant '{tenant}' does not exist. Use add_tenants() first."
)
return
curr_node: Node = self.root
i: int = 0
while i <= len(text):
# Invariant at beginning of each iteration: assume curr_node has not been visited by tenant yet.
# Update tenant info for current node.
if tenant not in curr_node.tenant_to_last_access_time:
self.tenant_to_char_count[tenant] += len(curr_node.text)
curr_node.tenant_to_last_access_time[tenant] = time_s
if curr_node != self.root:
self._remove_node_from_linked_list(curr_node, tenant)
self._insert_node_into_linked_list(
curr_node,
self.root,
self.root.tenant_to_older_node.get(tenant),
tenant,
)
if i == len(text):
break
first_char: str = text[i]
curr_text: str = text[i:]
if first_char not in curr_node.edge_label_to_child:
# No match, create new node. Don't update new node as "visited" by tenant yet; it will be done at the beginning of the next iteration.
# e.g. curr_node.edge_label_to_child = {}, curr_text = "hello" -> curr_node.edge_label_to_child = {"h": Node("hello")}
new_node: Node = Node(text=curr_text, parent=curr_node)
curr_node.edge_label_to_child[first_char] = new_node
# Add the node to the back of the LRU list; it will be moved to the front in the next iteration.
self._insert_node_into_linked_list(
new_node, self.tenant_to_lru_tail[tenant], None, tenant
)
# Match found, check if we need to split
matched_node: Node = curr_node.edge_label_to_child[first_char]
shared_count: int = self._shared_prefix_count(
matched_node.text, curr_text
)
if shared_count < len(matched_node.text):
# Partial match, split node at matched point
# Example:
## Before update:
### curr_node.edge_label_to_child = {"h": Node("helloworld")}, curr_text = "hellothere" -> shared_count = 5
### matched_node = Node("helloworld")
## After update:
### curr_node.edge_label_to_child = {"h": Node("hello", edge_label_to_child = {"w": Node("world")})}
### parent_node = Node("hello"), matched_node = Node("world")
### Copy matched_node.tenant_to_last_access_time to parent_node.tenant_to_last_access_time
### Insert parent_node into the back of the LRU list; it will be moved to the front in the next iteration. (for the current tenant)
### Insert parent_node between matched_node and matched_node's newer neighbor (for all other tenants)
### (new) curr_text = "there", (new) curr_node = parent_node
### Continue adding "there" to tree in next iteration
matched_text: str = matched_node.text[:shared_count]
remaining_text: str = matched_node.text[shared_count:]
# Create new intermediate node
# Note that we don't update new_parent.tenant_to_last_access_time yet; it will be done at the beginning of the next iteration.
new_parent: Node = Node(text=matched_text, parent=curr_node)
new_parent.tenant_to_last_access_time = (
matched_node.tenant_to_last_access_time.copy()
)
# Insert new_parent into the back of the LRU list; it will be moved to the front in the next iteration. (for the current tenant)
self._insert_node_into_linked_list(
new_parent, self.tenant_to_lru_tail[tenant], None, tenant
)
# Insert new_parent between matched_node and matched_node's newer neighbor (for all other tenants)
for existing_tenant in new_parent.tenant_to_last_access_time:
if existing_tenant != tenant:
self._insert_node_into_linked_list(
new_parent,
matched_node.tenant_to_newer_node.get(existing_tenant),
matched_node,
existing_tenant,
)
# Update existing matched node
matched_node.text = remaining_text
matched_node.parent = new_parent
# Connect nodes
new_parent.edge_label_to_child[remaining_text[0]] = matched_node
curr_node.edge_label_to_child[first_char] = new_parent
# Continue traversal
curr_node = new_parent
i += shared_count
else:
# Full match, continue down the tree
curr_node = matched_node
i += shared_count
def prefix_match(
self, text: str, available_tenants: Optional[List[str]] = None
) -> Tuple[str, Optional[List[str]]]:
"""
Match text against tree and return matched text and matching tenants.
Args:
text: Text to match
available_tenants: List of tenants to match against (or None for all)
Returns:
Tuple of (matched_text, matched_tenants):
If the list of available tenants doesn't match any tenants in the tree: returns ("", None)
When no prefix match is found (does not traverse further than the root node): returns ("", list of available tenants)
When a prefix match is found: returns (matched_prefix, list of tenants that own the matched node)
"""
with self.lock:
if available_tenants:
# Filter available_tenants to only include those in the tree
available_tenants = [
tenant
for tenant in available_tenants
if tenant in self.tenant_to_char_count
]
if not available_tenants:
return "", None
else:
available_tenants = list(self.tenant_to_char_count.keys())
curr_node: Node = self.root
i: int = 0
text_len: int = len(text)
while i < text_len:
first_char: str = text[i]
curr_text: str = text[i:]
if first_char in curr_node.edge_label_to_child:
matched_node: Node = curr_node.edge_label_to_child[first_char]
# Check if any available tenants match this node
if not any(
tenant in matched_node.tenant_to_last_access_time
for tenant in available_tenants
):
break
shared_count: int = self._shared_prefix_count(
matched_node.text, curr_text
)
i += shared_count
curr_node = matched_node
if shared_count < len(matched_node.text):
# Partial match, stop here
break
else:
# No match found, stop here
break
# Find tenants in current node that match available tenants
matched_tenants = [
tenant
for tenant in available_tenants
if tenant in curr_node.tenant_to_last_access_time
] or None
matched_text: str = text[:i]
return matched_text, matched_tenants
def remove_tenants(self, tenants: List[str]) -> Dict[str, int]:
"""
Remove multiple tenants and all their nodes from the tree.
Time complexity: O(n) where n is the total number of nodes owned by all tenants.
Args:
tenants: List of tenants to remove
Returns:
Dictionary mapping each tenant to the number of characters removed
(0 if tenant doesn't exist)
"""
chars_removed: Dict[str, int] = {}
with self.lock:
for tenant in tenants:
if tenant not in self.tenant_to_char_count:
logger.debug(f"Tenant '{tenant}' does not exist. Skipping.")
chars_removed[tenant] = 0
continue
tenant_chars_removed: int = 0
# Start from the tail and remove all nodes
current_tail = self.tenant_to_lru_tail.get(tenant)
while current_tail:
newer_neighbor = current_tail.tenant_to_newer_node.get(tenant)
tenant_chars_removed += self._remove_tenant_single_node(
tenant, current_tail
)
current_tail = newer_neighbor
# Clean up tenant references
self.tenant_to_char_count.pop(tenant, None)
self.tenant_to_lru_tail.pop(tenant, None)
chars_removed[tenant] = tenant_chars_removed
return chars_removed
def evict_tenant_by_lru(self, tenant: str, min_remove_size: int) -> int:
"""
Evict least recently used nodes for a tenant until minimum size is freed.
Time complexity: O(m) where m is the number of nodes removed.
Args:
tenant: The tenant to evict nodes from
min_remove_size: Minimum number of characters to remove
Returns:
Actual number of characters removed (0 if tenant doesn't exist)
Note:
- All nodes with the same oldest access time are removed together to maintain tree integrity, even if only removing a subset of them satisfies the min_remove_size.
- For more predictable eviction, use unique timestamps for each insertion.
- The root node is never evicted as it serves as the permanent head of the LRU list.
"""
with self.lock:
if tenant not in self.tenant_to_char_count:
logger.debug(
f"Cannot evict tenant '{tenant}': tenant does not exist. No action taken."
)
return 0
if self.tenant_to_char_count[tenant] < min_remove_size:
logger.debug(
f"Cannot evict {min_remove_size} characters from tenant '{tenant}', which has only "
f"{self.tenant_to_char_count[tenant]} characters. Will remove all available characters."
)
min_remove_size = self.tenant_to_char_count[tenant]
total_chars_removed: int = 0
# Start removing from the tail (least recently used)
current_tail = self.tenant_to_lru_tail.get(tenant)
# Continue until we've freed enough space or run out of nodes
while total_chars_removed < min_remove_size and current_tail:
# Stop if we've reached the root - the root is never evicted
if current_tail == self.root:
break
# Get the current timestamp to remove all nodes with this timestamp
current_timestamp = current_tail.tenant_to_last_access_time[tenant]
# Collect all nodes with the same timestamp (guaranteed to be contiguous in our LRU list)
while (
current_tail != self.root # Never include the root
and current_tail.tenant_to_last_access_time[tenant]
== current_timestamp
):
newer_neighbor = current_tail.tenant_to_newer_node.get(tenant)
total_chars_removed += self._remove_tenant_single_node(
tenant, current_tail
)
current_tail = newer_neighbor
return total_chars_removed
def get_smallest_tenants(self) -> Optional[List[str]]:
"""
Get the tenants with the smallest total character count.
Returns:
Tenants with smallest character count, or None if no tenants
"""
with self.lock:
if not self.tenant_to_char_count:
return None
min_count = min(self.tenant_to_char_count.values())
return [
tenant
for tenant, count in self.tenant_to_char_count.items()
if count == min_count
]
def start_eviction_loop(
self, eviction_threshold: int, eviction_target: int, interval_secs: float
) -> bool:
"""Start a single eviction loop within the actor itself.
Args:
eviction_threshold: Minimum number of characters a tenant must have to be evicted
eviction_target: The maximum number of characters a tenant should have after eviction
interval_secs: Number of seconds between eviction checks
Returns:
True if the loop was started, False if it was already running
"""
self._eviction_stop_event.clear()
with self.lock:
if self._eviction_thread is None:
self._eviction_thread = threading.Thread(
target=self._run_eviction_loop,
args=(eviction_threshold, eviction_target, interval_secs),
daemon=True,
)
self._eviction_thread.start()
return True
else:
logger.debug("Eviction loop already running")
return False
def _run_eviction_loop(self, eviction_threshold, eviction_target, interval_secs):
while not self._eviction_stop_event.is_set():
if self._eviction_stop_event.wait(interval_secs):
# Stop event was set, exit loop asap
break
with self.lock:
for tenant, char_count in self.tenant_to_char_count.items():
if char_count > eviction_threshold:
excess = char_count - eviction_target
self.evict_tenant_by_lru(tenant, excess)
def stop_eviction_loop(self):
self._eviction_stop_event.set()
if self._eviction_thread:
self._eviction_thread.join()
self._eviction_thread = None
@ray.remote
class PrefixTreeActor(PrefixTree):
def getattr(self, attribute: str) -> Any:
"""
Get an attribute of the PrefixTree.
Note: This method is intended to be used only in tests.
"""
return getattr(self, attribute)
def setattr(self, attribute: str, value: Any) -> None:
setattr(self, attribute, value)
@@ -0,0 +1,159 @@
import pprint
from typing import Any, Optional, Union
from pydantic import Field, field_validator
from ray import serve
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.dict_utils import deep_merge_dicts
from ray.llm._internal.serve.constants import RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.builder import (
IngressClsConfig,
_build_direct_streaming_llm_deployment,
_build_openai_ingress_request_router,
_validate_direct_streaming_ingress_config,
)
from ray.llm._internal.serve.core.ingress.ingress import (
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.server.builder import build_llm_deployment
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import (
DPServer,
)
from ray.serve.deployment import Application
logger = get_logger(__name__)
def build_dp_deployment(
llm_config: LLMConfig,
*,
name_prefix: Optional[str] = None,
bind_kwargs: Optional[dict] = None,
override_serve_options: Optional[dict] = None,
deployment_cls: Optional[type] = None,
) -> Application:
"""Build a data parallel attention LLM deployment.
Args:
llm_config: The LLM configuration.
name_prefix: The prefix to add to the deployment name.
bind_kwargs: Optional extra kwargs to pass to the deployment constructor.
Used by PD disaggregation to inject prefill_server handles.
override_serve_options: The optional serve options to override the
default options.
deployment_cls: Optional deployment class to use. Defaults to DPServer.
Returns:
The Ray Serve Application for the data parallel attention LLM deployment.
"""
return build_llm_deployment(
llm_config,
name_prefix=name_prefix,
bind_kwargs=bind_kwargs,
override_serve_options=override_serve_options,
deployment_cls=deployment_cls or DPServer,
)
class DPOpenAiServingArgs(BaseModelExtended):
"""Schema for DP OpenAI serving args."""
llm_config: Union[str, dict, LLMConfig] = Field(
description="The LLM configuration",
)
ingress_cls_config: Union[dict, IngressClsConfig] = Field(
default_factory=IngressClsConfig,
description="The configuration for the ingress class.",
)
ingress_deployment_config: Optional[dict] = Field(
default_factory=dict,
description="The Ray @server.deployment options for the ingress server.",
)
@field_validator("llm_config")
@classmethod
def _validate_llm_config(cls, value: Any) -> LLMConfig:
if isinstance(value, str):
return LLMConfig.from_file(value)
elif isinstance(value, dict):
return LLMConfig.model_validate(value)
elif isinstance(value, LLMConfig):
return value
else:
raise TypeError(f"Invalid LLMConfig type: {type(value)}")
@field_validator("ingress_cls_config")
@classmethod
def _validate_ingress_cls_config(cls, value: Any) -> IngressClsConfig:
if isinstance(value, dict):
return IngressClsConfig.model_validate(value)
return value
def build_dp_openai_app(builder_config: dict) -> Application:
"""Build an OpenAI compatible app with the DP attention deployment
setup from the given builder configuration.
Args:
builder_config: The configuration for the builder. It has to conform
to the DPOpenAiServingArgs pydantic model.
Returns:
The configured Ray Serve Application.
"""
builder_config = DPOpenAiServingArgs.model_validate(builder_config)
llm_config = builder_config.llm_config
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
_validate_direct_streaming_ingress_config(
builder_config.ingress_deployment_config,
builder_config.ingress_cls_config,
)
direct_deployment = _build_direct_streaming_llm_deployment(
llm_config,
deployment_cls=DPServer,
)
logger.info(
"Direct streaming enabled for DP: "
"DPServer=ingress, LLMRouter=ingress_request_router"
)
return direct_deployment._with_ingress_request_router(
_build_openai_ingress_request_router(
server=direct_deployment, llm_config=llm_config
)
)
dp_deployment = build_dp_deployment(llm_config)
ingress_cls_config = builder_config.ingress_cls_config
ingress_options = ingress_cls_config.ingress_cls.get_deployment_options(
[llm_config]
)
if builder_config.ingress_deployment_config:
ingress_options = deep_merge_dicts(
ingress_options, builder_config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(ingress_cls_config.ingress_cls)
logger.info("============== Ingress Options ==============")
logger.info(pprint.pformat(ingress_options))
model_id = llm_config.model_id
lora_config = llm_config.lora_config
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={model_id: dp_deployment},
model_cards={model_id: to_model_metadata(model_id, llm_config)},
lora_paths=(
{model_id: lora_config.dynamic_lora_loading_path}
if lora_config is not None
else {}
),
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,297 @@
import asyncio
import json
import logging
import os
import time
from typing import List, Optional, Tuple, Type
import ray
from ray import serve
from ray.experimental.internal_kv import (
_internal_kv_del,
_internal_kv_get,
_internal_kv_put,
)
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.engine.protocol import LLMEngine
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm._internal.serve.utils.lora_serve_utils import LoraModelLoader
from ray.llm._internal.serve.utils.pg_utils import get_bundle_indices_sorted_by_node
from ray.serve.config import (
AutoscalingConfig,
GangPlacementStrategy,
GangRuntimeFailurePolicy,
GangSchedulingConfig,
)
from ray.util.collective.collective import get_address_and_port
from ray.util.placement_group import get_placement_group
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 120
POLL_INTERVAL_SECONDS = 0.5
class GangMasterInfoRegistry:
"""Registry for gang DP master info using GCS KV store."""
_KEY_PREFIX = "LLMServeRegistry:serve_global:gang_dp_master/"
@classmethod
def _make_key(cls, gang_id: str) -> bytes:
return (cls._KEY_PREFIX + gang_id).encode("utf-8")
@classmethod
def register(cls, gang_id: str, address: str, port: int, node_id: str) -> None:
"""Store the DP master info in GCS KV store."""
key = cls._make_key(gang_id)
value = json.dumps(
{"address": address, "port": port, "node_id": node_id}
).encode("utf-8")
_internal_kv_put(key, value, overwrite=True)
@classmethod
def unregister(cls, gang_id: str) -> None:
"""Remove the DP master info from GCS KV store."""
key = cls._make_key(gang_id)
try:
_internal_kv_del(key)
except Exception:
logger.warning(
f"Failed to unregister gang master info for gang {gang_id}.",
exc_info=True,
)
@classmethod
async def get(
cls,
gang_id: str,
timeout: float = TIMEOUT_SECONDS,
poll_interval: float = POLL_INTERVAL_SECONDS,
) -> Tuple[str, int, str]:
"""Retrieve the DP master info for gang_id, polling until available.
Args:
gang_id: The ID of the gang.
timeout: The timeout in seconds.
poll_interval: The poll interval in seconds.
Returns:
A tuple of (address, port, node_id).
Raises:
TimeoutError: If the info is not available within timeout_seconds seconds.
"""
key = cls._make_key(gang_id)
deadline = time.monotonic() + timeout
while True:
data = _internal_kv_get(key)
if data is not None:
info = json.loads(data)
return info["address"], info["port"], info["node_id"]
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out waiting for DP master info for gang {gang_id} "
f"after {timeout}s."
)
await asyncio.sleep(poll_interval)
class DPServer(LLMServer):
"""
Gang-scheduled Data Parallel LLM Server.
Uses Ray Serve's gang scheduling so that if any replica in a DP group deployment
fails, the entire group is restarted atomically.
"""
async def __init__(
self,
llm_config: LLMConfig,
*,
engine_cls: Optional[Type[LLMEngine]] = None,
model_downloader: Optional[Type[LoraModelLoader]] = None,
):
ctx = serve.get_replica_context()
gang_context = ctx.gang_context
if gang_context is None:
raise RuntimeError(
"DPServer requires gang scheduling to be enabled. "
"Set gang_scheduling_config in the deployment options."
)
self.dp_rank = gang_context.rank
self.gang_id = gang_context.gang_id
self.dp_size = gang_context.world_size
logger.info(
f"DPServer replica initialized: dp_rank={self.dp_rank}, "
f"dp_size={self.dp_size}, gang_id={self.gang_id}"
)
if self.dp_rank == 0:
self.dp_address, self.dp_rpc_port = get_address_and_port()
# Record rank 0's node so every replica places vLLM's global rank 0
# worker (which hosts the distributed rendezvous store) on the same
# node whose address we advertise below. See the bundle-ordering
# comment in __init__ for why this matters.
self.dp_node_id = ray.get_runtime_context().get_node_id()
GangMasterInfoRegistry.register(
self.gang_id, self.dp_address, self.dp_rpc_port, self.dp_node_id
)
logger.info(
f"DP rank {self.dp_rank} has set DP master info: "
f"data_parallel_address={self.dp_address}, "
f"data_parallel_rpc_port={self.dp_rpc_port}, "
f"data_parallel_node_id={self.dp_node_id}"
)
else:
timestamp = time.time()
(
self.dp_address,
self.dp_rpc_port,
self.dp_node_id,
) = await GangMasterInfoRegistry.get(self.gang_id)
logger.info(
f"DP rank {self.dp_rank} got DP master info: "
f"data_parallel_address={self.dp_address}, "
f"data_parallel_rpc_port={self.dp_rpc_port}, "
f"data_parallel_node_id={self.dp_node_id}, "
f"waited {time.time() - timestamp:.3f} seconds"
)
# Update the engine_kwargs to assign the DP information
llm_config.update_engine_kwargs(
data_parallel_rank=self.dp_rank,
data_parallel_address=self.dp_address,
data_parallel_rpc_port=self.dp_rpc_port,
)
engine_config = llm_config.get_engine_config()
# Direct vLLM to use this replica's bundles within the gang placement group.
# Gang placement group concatenates per-replica bundles for all ranks, so
# rank i owns bundles [i*B, i*B+1, ..., i*B+B-1] where B is the number of
# bundles per DP replica.
#
# However, adjacent bundle indices in a placement group don't necessarily
# map to adjacent physical ranks. We use get_bundle_indices_sorted_by_node
# to reorder bundle indices so that same-node bundles are adjacent and
# rank 0's node bundles come first. This prevents us from scattering
# adjacent TP ranks in the same DP rank across nodes.
#
# Ordering rank 0's node first is also required for correctness: vLLM
# forms a single distributed group across all DP workers whose rendezvous
# store is hosted by global rank 0 and reached at the advertised
# data_parallel_address (set above from rank 0's node). vLLM pins global
# rank 0 to sorted_indices[0], so that bundle must live on the same node
# whose address we advertised. Sorting by the cluster head node instead
# (the previous default) breaks this whenever the head node owns no
# bundles in the gang (e.g. a CPU-only head in a GPU cluster): rank 0
# then lands on an arbitrary node, the store binds there, and every
# worker hangs connecting to the wrong (advertised) address until the
# distributed-init timeout fires and the gang is restarted.
#
# Example: dp_size=2, tp_size=2, 2 GPUs per node for simplicity
# Gang placement group = [{GPU: 1}, {GPU: 1}, {GPU: 1}, {GPU: 1}]
# Physical rank location: ^^N0R0^^ ^^N1R1^^ ^^N0R1^^ ^^N1R0^^
# DP placement: ^^DP0^^^ ^^DP1^^^ ^^DP0^^^ ^^DP1^^^
#
# placement_bundles below is the gang placement group, and therefore
# get_current_placement_group from the actor yields the gang placement group,
# not the per-replica placement group.
bundles_per_replica = len(engine_config.placement_bundles)
pg = get_placement_group(gang_context.pg_name)
sorted_indices = get_bundle_indices_sorted_by_node(
pg, driver_node_id=self.dp_node_id
)
os.environ["VLLM_RAY_BUNDLE_INDICES"] = self._compute_bundle_indices(
self.dp_rank, bundles_per_replica, sorted_indices
)
await super().__init__(
llm_config,
engine_cls=engine_cls,
model_downloader=model_downloader,
)
@staticmethod
def _compute_bundle_indices(
dp_rank: int, bundles_per_replica: int, sorted_indices: List[int]
) -> str:
"""Return the VLLM_RAY_BUNDLE_INDICES value for a given DP rank.
Slices into sorted_indices (bundle indices reordered so that
same-node bundles are adjacent) to pick the bundles that belong to
this DP rank.
Args:
dp_rank: This replica's data-parallel rank.
bundles_per_replica: Number of placement-group bundles each DP
replica owns.
sorted_indices: Bundle indices sorted by node.
Returns:
Comma-separated bundle indices, e.g. "0,2".
"""
start = dp_rank * bundles_per_replica
return ",".join(
str(sorted_indices[start + i]) for i in range(bundles_per_replica)
)
@classmethod
def get_deployment_options(cls, llm_config: "LLMConfig"):
deployment_options = super().get_deployment_options(llm_config)
dp_size = llm_config.engine_kwargs.get("data_parallel_size", 1)
if not (isinstance(dp_size, int) and dp_size > 0):
raise ValueError(
f"Invalid data_parallel_size: {dp_size}, expecting positive integer."
)
if dp_size != 1:
num_replicas = deployment_options.get("num_replicas")
has_autoscaling = num_replicas == "auto" or (
num_replicas is None and "autoscaling_config" in deployment_options
)
if has_autoscaling:
autoscaling_config = AutoscalingConfig.default().dict()
user_config = deployment_options.get("autoscaling_config")
if user_config is not None:
autoscaling_config.update(user_config)
logger.warning(
"In DP deployment, a replica refers to a DP group. "
"Multiplying autoscaling_config's min_replicas, max_replicas, and "
f"initial_replicas by data_parallel_size ({dp_size})."
)
for key in ["min_replicas", "max_replicas", "initial_replicas"]:
if autoscaling_config.get(key) is not None:
autoscaling_config[key] *= dp_size
deployment_options["autoscaling_config"] = autoscaling_config
elif num_replicas is not None:
logger.warning(
"In DP deployment, num_replicas refers to the number of DP groups. "
f"Multiplying num_replicas ({num_replicas}) by data_parallel_size ({dp_size}) "
f"to get the total number of serve replicas ({num_replicas * dp_size})."
)
deployment_options["num_replicas"] = num_replicas * dp_size
else:
deployment_options["num_replicas"] = dp_size
deployment_options["gang_scheduling_config"] = GangSchedulingConfig(
gang_size=dp_size,
gang_placement_strategy=GangPlacementStrategy.PACK,
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_GANG,
)
# Remove per-replica placement_group_strategy. Ray Serve raises an error
# if both placement_group_strategy and gang_scheduling_config are provided.
if "placement_group_strategy" in deployment_options:
logger.warning(
"placement_group_strategy configured in the deployment config is ignored. "
"DP deployment uses PACK strategy for scheduling DP groups."
)
deployment_options.pop("placement_group_strategy", None)
return deployment_options
@@ -0,0 +1,305 @@
"""Using Ray Serve to deploy LLM models with P/D disaggregation.
3-tier graph: ingress -> PDDecodeServer (decode config + engine) -> PDPrefillServer.
"""
import warnings
from typing import Any, Optional, Union
from pydantic import Field, field_validator, model_validator
from ray import serve
from ray.llm._internal.common.base_pydantic import BaseModelExtended
from ray.llm._internal.common.dict_utils import (
maybe_apply_llm_deployment_config_defaults,
)
from ray.llm._internal.serve.constants import RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.builder import (
IngressClsConfig,
_build_direct_streaming_llm_deployment,
_build_openai_ingress_request_router,
_validate_direct_streaming_ingress_config,
load_class,
)
from ray.llm._internal.serve.core.ingress.ingress import (
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.server.builder import build_llm_deployment
from ray.llm._internal.serve.observability.logging import get_logger
from ray.llm._internal.serve.serving_patterns.data_parallel.builder import (
build_dp_deployment,
)
from ray.llm._internal.serve.serving_patterns.prefill_decode.pd_server import (
DPPDDecodeServer,
DPPDPrefillServer,
PDDecodeServer,
PDPrefillServer,
PDProxyServer, # TODO(Kourosh): Deprecate, remove in Ray 2.58.
)
from ray.serve.deployment import Application
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Deprecated: ProxyClsConfig
# TODO(Kourosh): Deprecate, remove in Ray 2.58.
# ---------------------------------------------------------------------------
class ProxyClsConfig(BaseModelExtended):
"""Deprecated. Unused proxy configuration kept for backwards compatibility."""
proxy_cls: Union[str, type] = Field(
default=PDProxyServer,
description="Deprecated.",
)
proxy_extra_kwargs: Optional[dict] = Field(
default_factory=dict,
description="Deprecated.",
)
@field_validator("proxy_cls")
@classmethod
def validate_class(cls, value):
if isinstance(value, str):
return load_class(value)
return value
# ---------------------------------------------------------------------------
# PDServingArgs
# ---------------------------------------------------------------------------
class PDServingArgs(BaseModelExtended):
"""Schema for P/D serving args.
Defines the prefill and decode LLMConfigs plus ingress options.
The deprecated ``proxy_cls_config`` and ``proxy_deployment_config``
fields are accepted for backwards compatibility but ignored.
"""
prefill_config: Union[str, dict, LLMConfig]
decode_config: Union[str, dict, LLMConfig]
# TODO(Kourosh): Deprecated, remove in Ray 2.58.
# Deprecated proxy fields — accepted for backwards compat, ignored at build time.
proxy_cls_config: Optional[Union[dict, ProxyClsConfig]] = Field(
default=None,
description="Deprecated. Accepted but ignored.",
)
proxy_deployment_config: Optional[dict] = Field(
default=None,
description="Deprecated. Accepted but ignored.",
)
ingress_cls_config: Union[dict, IngressClsConfig] = Field(
default_factory=IngressClsConfig,
description="The configuration for the ingress class.",
)
ingress_deployment_config: Optional[dict] = Field(
default_factory=dict,
description="The Ray @serve.deployment options for the ingress.",
)
@field_validator("prefill_config", "decode_config")
@classmethod
def _validate_llm_config(cls, value: Any) -> LLMConfig:
if isinstance(value, str):
return LLMConfig.from_file(value)
elif isinstance(value, dict):
return LLMConfig.model_validate(value)
elif isinstance(value, LLMConfig):
return value
else:
raise TypeError(f"Invalid LLMConfig type: {type(value)}")
@field_validator("proxy_cls_config")
@classmethod
def _validate_proxy_cls_config(
cls, value: Optional[Union[dict, ProxyClsConfig]]
) -> Optional[ProxyClsConfig]:
if value is not None:
warnings.warn(
"proxy_cls_config is deprecated and ignored. "
"The proxy has been replaced by PDDecodeServer which "
"orchestrates prefill and decode directly. "
"See PDDecodeServer and PDPrefillServer.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(value, dict):
return ProxyClsConfig.model_validate(value)
return value
@field_validator("proxy_deployment_config")
@classmethod
def _validate_proxy_deployment_config(cls, value: Optional[dict]) -> Optional[dict]:
if value is not None:
warnings.warn(
"proxy_deployment_config is deprecated and ignored. "
"The proxy has been replaced by PDDecodeServer which "
"orchestrates prefill and decode directly. "
"See PDDecodeServer and PDPrefillServer.",
DeprecationWarning,
stacklevel=2,
)
return value
@field_validator("ingress_cls_config")
@classmethod
def _validate_ingress_cls_config(
cls, value: Union[dict, IngressClsConfig]
) -> IngressClsConfig:
if isinstance(value, dict):
return IngressClsConfig.model_validate(value)
return value
@model_validator(mode="after")
def _validate_model_ids(self):
"""Validate that prefill and decode configs use the same model ID."""
if self.prefill_config.model_id != self.decode_config.model_id:
raise ValueError("P/D model id mismatch")
return self
@model_validator(mode="after")
def _validate_kv_transfer_config(self):
"""Validate that kv_transfer_config is set for both prefill and decode configs."""
for config in [self.prefill_config, self.decode_config]:
if config.engine_kwargs.get("kv_transfer_config") is None:
raise ValueError(
"kv_transfer_config is required for P/D disaggregation"
)
return self
@model_validator(mode="after")
def _default_decode_nixl_port_base(self):
"""Shift decode's NIXL base off prefill's default (20000) so colocated replicas don't collide."""
self.decode_config.experimental_configs.setdefault(
"NIXL_SIDE_CHANNEL_PORT_BASE", 22000
)
return self
@model_validator(mode="after")
def _default_decode_moriio_port_base(self):
"""Shift decode's MoRIIO handshake/notify bases off prefill's defaults.
Mirrors ``_default_decode_nixl_port_base``: a colocated P+D pair on one
node would otherwise share MoRIIO's default handshake/notify ports. Only
applies when the decode config uses the MoRIIO connector. The +1000
stride is well above any realistic tp_size*pp_size offset added on top.
"""
kv_transfer_config = (
self.decode_config.engine_kwargs.get("kv_transfer_config") or {}
)
if kv_transfer_config.get("kv_connector") != "MoRIIOConnector":
return self
from ray.llm._internal.serve.engines.vllm.kv_transfer.moriio import (
DEFAULT_HANDSHAKE_PORT_BASE,
DEFAULT_NOTIFY_PORT_BASE,
HANDSHAKE_PORT_BASE_KEY,
NOTIFY_PORT_BASE_KEY,
)
self.decode_config.experimental_configs.setdefault(
HANDSHAKE_PORT_BASE_KEY, DEFAULT_HANDSHAKE_PORT_BASE + 1000
)
self.decode_config.experimental_configs.setdefault(
NOTIFY_PORT_BASE_KEY, DEFAULT_NOTIFY_PORT_BASE + 1000
)
return self
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_pd_openai_app(pd_serving_args: dict) -> Application:
"""Build a deployable application utilizing prefill/decode disaggregation.
3-tier graph: ingress -> PDDecodeServer -> PDPrefillServer.
"""
pd_config = PDServingArgs.model_validate(pd_serving_args)
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
_validate_direct_streaming_ingress_config(
pd_config.ingress_deployment_config,
pd_config.ingress_cls_config,
)
prefill_dp_size = pd_config.prefill_config.engine_kwargs.get(
"data_parallel_size", 1
)
decode_dp_size = pd_config.decode_config.engine_kwargs.get("data_parallel_size", 1)
prefill_builder = (
build_dp_deployment if prefill_dp_size > 1 else build_llm_deployment
)
# When DP > 1, use combined DP+PD server classes that inherit from both
# the PD server and DPServer (for gang scheduling, DP master info, etc.).
prefill_cls = DPPDPrefillServer if prefill_dp_size > 1 else PDPrefillServer
decode_cls = DPPDDecodeServer if decode_dp_size > 1 else PDDecodeServer
prefill_deployment = prefill_builder(
pd_config.prefill_config,
name_prefix="Prefill:",
deployment_cls=prefill_cls,
)
if RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING:
# Direct streaming makes decode the ASGI ingress, so it must be built
# with the ASGI wrapper while still receiving the prefill backend.
decode_deployment = _build_direct_streaming_llm_deployment(
pd_config.decode_config,
name_prefix="Decode:",
bind_kwargs={"prefill_server": prefill_deployment},
deployment_cls=decode_cls,
)
logger.info(
"Direct streaming enabled for PD: "
f"{decode_cls.__name__}=ingress, LLMRouter=ingress_request_router"
)
return decode_deployment._with_ingress_request_router(
_build_openai_ingress_request_router(
server=decode_deployment, llm_config=pd_config.decode_config
)
)
decode_builder = build_dp_deployment if decode_dp_size > 1 else build_llm_deployment
decode_deployment = decode_builder(
pd_config.decode_config,
name_prefix="Decode:",
bind_kwargs={"prefill_server": prefill_deployment},
deployment_cls=decode_cls,
)
# -- Ingress: binds to decode only (the "model" the client sees) --
ingress_cls_config = pd_config.ingress_cls_config
default_ingress_options = ingress_cls_config.ingress_cls.get_deployment_options(
[pd_config.decode_config]
)
ingress_options = maybe_apply_llm_deployment_config_defaults(
default_ingress_options, pd_config.ingress_deployment_config
)
ingress_cls = make_fastapi_ingress(ingress_cls_config.ingress_cls)
# Prefill and decode share the same model_id (validated in PDServingArgs).
# Ingress binds to decode only (the "model" the client sees).
model_id = pd_config.decode_config.model_id
lora_config = pd_config.decode_config.lora_config
return serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={model_id: decode_deployment},
model_cards={model_id: to_model_metadata(model_id, pd_config.decode_config)},
lora_paths=(
{model_id: lora_config.dynamic_lora_loading_path}
if lora_config is not None
else {}
),
**ingress_cls_config.ingress_extra_kwargs,
)
@@ -0,0 +1,870 @@
"""Prefill-Decode disaggregated LLM serving: decode-as-orchestrator architecture.
3-tier graph (ingress -> PDDecodeServer -> PDPrefillServer) where the
decode deployment owns a real engine and orchestrates remote prefill.
"""
import asyncio
import contextlib
import logging
import uuid
import warnings
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
from fastapi.routing import APIRoute
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from ray.llm._internal.common.patches.vllm.tokenize_once import (
install as _install_tokenize_once,
reuse_prompt_token_ids as _reuse_prompt_token_ids,
)
from ray.llm._internal.serve.constants import DEFAULT_MAX_ONGOING_REQUESTS
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
CompletionRequest,
CompletionResponse,
EmbeddingRequest,
EmbeddingResponse,
ErrorResponse,
)
from ray.llm._internal.serve.core.ingress.utils import (
NON_STREAMING_RESPONSE_TYPES,
_openai_json_wrapper,
_peek_at_generator,
_sanitize_chat_completion_request,
)
from ray.llm._internal.serve.core.protocol import LLMServerProtocol, RawRequestInfo
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import BaseConnectorBackend
from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import DPServer
from ray.llm._internal.serve.utils.broadcast import broadcast
from ray.llm._internal.serve.utils.server_utils import (
get_serve_request_id,
)
from ray.serve._private.http_util import session_id_from_headers
from ray.serve.exceptions import DeploymentUnavailableError
from ray.serve.handle import DeploymentHandle
from ray.serve.llm import LLMConfig
logger = logging.getLogger(__name__)
RequestType = Union[ChatCompletionRequest, CompletionRequest]
# TODO(Kourosh): Deprecate in Ray 2.56, remove in Ray 2.58.
DEFAULT_PD_PROXY_SERVER_OPTIONS = {
"max_ongoing_requests": DEFAULT_MAX_ONGOING_REQUESTS,
}
_PREWARM_PROMPT = " x"
_PREWARM_MAX_TOKENS = 1
_PREWARM_RETRY_INTERVAL_S = 5.0
_PREWARM_MAX_RETRIES = 60
# ---------------------------------------------------------------------------
# Direct-streaming route helpers
# ---------------------------------------------------------------------------
#
# Direct streaming exposes the engine-native ASGI app directly on the LLM
# server replica (see ``LLMServer.__serve_build_asgi_app__``), eliminating the
# separate ``OpenAiIngress`` deployment. For P/D, the engine-native
# chat/completions routes would send traffic straight to the local decode
# engine and bypass remote prefill, so ``PDOrchestratorMixin`` re-points just
# those two routes at its own ``chat`` / ``completions`` (which orchestrate
# prefill then decode). Every other route stays engine-native, identical to
# non-P/D direct streaming.
def _strip_routes(app, path: str) -> None:
"""Remove the engine-native APIRoute(s) registered at ``path``."""
app.routes[:] = [
r for r in app.routes if not (isinstance(r, APIRoute) and r.path == path)
]
async def _pd_http_response(gen) -> Response:
"""Shape a P/D orchestration generator into an OpenAI HTTP response.
Returns a JSON response when the first chunk is an error or a complete
(non-streaming) response, otherwise an SSE stream. Uses the same response
helpers as ``OpenAiIngress`` so the wire format matches the standard path.
"""
first, gen = await _peek_at_generator(gen)
if isinstance(first, list):
first = first[0]
if isinstance(first, ErrorResponse):
return JSONResponse(
content=first.model_dump(), status_code=first.error.code or 400
)
if isinstance(first, NON_STREAMING_RESPONSE_TYPES):
return JSONResponse(content=first.model_dump())
return StreamingResponse(_openai_json_wrapper(gen), media_type="text/event-stream")
# ---------------------------------------------------------------------------
# Mixin: PD Orchestration Logic
# ---------------------------------------------------------------------------
class PDOrchestratorMixin:
"""Mixin that adds prefill-decode orchestration to an LLMServer subclass.
For chat/completions requests it:
1. Sends a modified prefill request (max_tokens=1, kv_transfer_params).
2. Receives kv_transfer_params back from the first prefill chunk.
3. Runs decode locally on its own engine with those params.
"""
# Set by __init__ of the concrete class that mixes this in.
_prefill_handle: DeploymentHandle
# Decode reuses prefill's prompt token ids. Set from experimental_configs in
# PDDecodeServer.__init__. Default off.
_pd_tokenize_once: bool = False
# ---- Connector backend resolution ----
def _get_connector_backend(self) -> BaseConnectorBackend:
"""Return the connector backend that was set up during engine init.
``LLMConfig.setup_engine_backend()`` creates the backend and calls its
``setup()`` during engine initialization, storing it on the config. By the
time a request reaches the orchestrator it must already be there — a
missing backend means engine init was skipped, which is a bug (and a
freshly-created, un-``setup()`` backend would mis-shape traffic, e.g. a
MultiConnector whose sub-connectors are populated only in ``setup()``).
Cached on first access since the request path calls this.
"""
cached = getattr(self, "_connector_backend_cache", None)
if cached is not None:
return cached
backend = getattr(self._llm_config, "kv_connector_backend", None)
assert backend is not None, (
"No KV-connector backend on the LLMConfig. setup_engine_backend() must "
"run during engine init before the P/D orchestrator handles requests."
)
self._connector_backend_cache = backend
return backend
# ---- Request Preparation ----
#
# Thin instance delegates to the resolved backend's protocol so existing
# callers/tests that reference these names keep working. The orchestrator
# itself goes through ``backend.prepare_*`` directly.
def _prepare_prefill_request(self, request: RequestType) -> RequestType:
return self._get_connector_backend().prepare_prefill_request(
request=request, peer=None
)
def _prepare_decode_request(
self,
request: RequestType,
prefill_chunk: Union[ChatCompletionResponse, CompletionResponse],
) -> RequestType:
return self._get_connector_backend().prepare_decode_request(
request=request, peer=None, prefill_response=prefill_chunk
)
def _decode_reuse_ids(self, prefill_chunk) -> Optional[list]:
"""Prompt token ids for decode to reuse, or None when disabled or absent.
Chat carries them top-level. Completions carry them on the first choice as
``CompletionResponseChoice.prompt_token_ids``."""
if not self._pd_tokenize_once:
return None
ids = getattr(prefill_chunk, "prompt_token_ids", None)
if ids is None:
choices = getattr(prefill_chunk, "choices", None)
if choices:
ids = getattr(choices[0], "prompt_token_ids", None)
return ids
def _request_prefill_token_ids(self, prefill_request) -> None:
"""Ask prefill to echo its prompt token ids so decode can reuse them.
No-op when disabled or the request lacks the field. Used on sequential handoff
only. Concurrent decode starts before prefill returns, so it has nothing to
reuse."""
if self._pd_tokenize_once and hasattr(prefill_request, "return_token_ids"):
prefill_request.return_token_ids = True
# ---- Orchestrated Request Flow ----
async def _pd_handle_request(
self,
request: RequestType,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[str, ChatCompletionResponse, CompletionResponse, ErrorResponse], None
]:
"""Orchestrate prefill (remote) then decode (local engine).
Request shaping, peer addressing, and handoff discipline are delegated to
the resolved KV-connector backend. With the default backend flags
(``requires_peer_binding=False``, ``concurrent_handoff=False``) the
control flow and calls are identical to the historical NIXL/default flow.
A connector that encodes coordination data in the request id (MoRIIO's
dual-address id) just stamps ``request.request_id`` in ``prepare_*``; it
then reaches both engines unchanged -- the LLMServer pipeline preserves
an explicitly-set request_id (it no longer clobbers it with the Serve
id) and the engine copies it into the ``X-Request-Id`` header it reads.
"""
# Determine method name for the handle call
if isinstance(request, ChatCompletionRequest):
method = "chat"
elif isinstance(request, CompletionRequest):
method = "completions"
else:
raise ValueError(f"Unsupported request type: {type(request)}")
backend = self._get_connector_backend()
prefill_handle = self._prefill_handle
if raw_request_info is not None:
session_id = session_id_from_headers(raw_request_info.headers)
if session_id:
prefill_handle = prefill_handle.options(session_id=session_id)
prefill_handle_method = getattr(prefill_handle, method)
if backend.requires_peer_binding:
# Connector needs to bind to the selected prefill replica *before*
# dispatch (e.g. request-id-addressed transfers). Reserve a replica
# via choose_replica, expose its metadata to the backend, then
# dispatch onto that exact selection.
async with prefill_handle_method.choose_replica(request) as selection:
# The selected replica's published metadata (empty dict if none).
peer = getattr(selection, "replica_metadata", {})
prefill_request = backend.prepare_prefill_request(
request=request, peer=peer
)
if backend.concurrent_handoff:
# Concurrent handoff: start remote prefill and run local decode
# together, draining prefill before leaving the choose_replica
# context (so on_request_completed fires once).
decode_request = backend.prepare_decode_request(
request=request, peer=peer, prefill_response=None
)
prefill_resp = prefill_handle_method.dispatch(
selection, prefill_request, raw_request_info
)
# dispatch()'s completion accounting fires when its result
# completes, so the response must be drained to exhaustion
# inside the choose_replica context — never cancelled
# (prefill is clamped to a single token, so draining is
# bounded).
async for chunk in self._concurrent_decode(
method,
decode_request,
prefill_resp,
raw_request_info,
cancel_on_failure=False,
):
yield chunk
return
# Sequential handoff with peer binding: run prefill to its first
# chunk, then drive local decode with the returned params.
self._request_prefill_token_ids(prefill_request)
prefill_gen = prefill_handle_method.dispatch(
selection, prefill_request, raw_request_info
)
prefill_chunk = await prefill_gen.__anext__()
# Drain the dispatched stream to exhaustion inside the
# choose_replica context: dispatch()'s completion accounting
# (queue-length cache decrement) fires when the result
# completes. Prefill is clamped to a single token, so this is
# at most one trivial extra iteration.
async for _ in prefill_gen:
pass
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
decode_request = backend.prepare_decode_request(
request=request, peer=peer, prefill_response=prefill_chunk
)
with _reuse_prompt_token_ids(self._decode_reuse_ids(prefill_chunk)):
local_gen = await getattr(super(), method)(
decode_request, raw_request_info
)
async for chunk in local_gen:
yield chunk
return
# Default path: no pre-dispatch peer binding; dispatch prefill via the
# standard handle path.
prefill_request = backend.prepare_prefill_request(request=request, peer=None)
if backend.concurrent_handoff:
# Concurrent handoff: dispatch via remote() and run local decode
# together.
decode_request = backend.prepare_decode_request(
request=request, peer=None, prefill_response=None
)
prefill_resp = prefill_handle_method.remote(
prefill_request, raw_request_info
)
async for chunk in self._concurrent_decode(
method, decode_request, prefill_resp, raw_request_info
):
yield chunk
return
# 1. Remote prefill
self._request_prefill_token_ids(prefill_request)
prefill_gen = prefill_handle_method.remote(prefill_request, raw_request_info)
prefill_chunk = await prefill_gen.__anext__()
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
# 2. Local decode via super().chat / super().completions so the
# standard LLMServer request pipeline (request_id, LoRA multiplex,
# batch_output_stream) runs on the decode side.
decode_request = backend.prepare_decode_request(
request=request, peer=None, prefill_response=prefill_chunk
)
# Reuse prefill's ids for this decode so the render skips re-tokenizing.
with _reuse_prompt_token_ids(self._decode_reuse_ids(prefill_chunk)):
local_gen = await getattr(super(), method)(decode_request, raw_request_info)
async for chunk in local_gen:
yield chunk
async def _concurrent_decode(
self,
method: str,
decode_request: RequestType,
prefill_resp: AsyncGenerator,
raw_request_info: Optional[RawRequestInfo],
*,
cancel_on_failure: bool = True,
):
"""Run local decode while a remote prefill drains concurrently.
While prefill is in flight, each decode chunk is raced against the
prefill task so a prefill failure surfaces to the client as an
``ErrorResponse`` (instead of a hung — decode may be waiting on KV that
will never arrive — or seemingly-successful decode stream). The
background prefill task is always awaited so it never leaks on the
prefill/decode engines.
Args:
method: The handle method name ("chat" or "completions").
decode_request: The request to run on the local decode engine.
prefill_resp: The in-flight remote prefill response stream.
raw_request_info: Raw HTTP request info forwarded to the engine.
cancel_on_failure: Whether to cancel the in-flight prefill when
local decode does not complete. Must be False for
``dispatch()``-based prefill (the choose_replica path): its
completion accounting fires when the response completes, so the
stream must be drained to exhaustion, never abandoned. Prefill
is clamped to a single token, so draining is bounded either way.
"""
prefill_task = asyncio.ensure_future(_drain_prefill(prefill_resp))
completed = False
local_gen = None
next_fut = None
try:
local_gen = await getattr(super(), method)(decode_request, raw_request_info)
gen = local_gen.__aiter__()
while True:
# Surface a failed prefill as soon as it is observed.
if prefill_task.done() and isinstance(
prefill_task.result(), ErrorResponse
):
err = prefill_task.result()
logger.error("Remote prefill returned error: %s", err)
yield err
return
if next_fut is None:
next_fut = asyncio.ensure_future(gen.__anext__())
# Race the next decode chunk against the in-flight prefill;
# once prefill has completed (successfully), just stream.
awaitables = {next_fut}
if not prefill_task.done():
awaitables.add(prefill_task)
done, _ = await asyncio.wait(
awaitables, return_when=asyncio.FIRST_COMPLETED
)
if next_fut in done:
try:
chunk = next_fut.result()
except StopAsyncIteration:
break
next_fut = None
yield chunk
# else: prefill finished first; loop back to inspect it.
completed = True
finally:
if next_fut is not None and not next_fut.done():
next_fut.cancel()
with contextlib.suppress(BaseException):
await next_fut
if not completed:
# Abort the local decode request if we bailed early.
if local_gen is not None:
with contextlib.suppress(BaseException):
await local_gen.aclose()
if cancel_on_failure:
prefill_task.cancel()
try:
err = await prefill_task
if isinstance(err, ErrorResponse):
logger.error("Remote prefill returned error: %s", err)
except asyncio.CancelledError:
pass
except Exception as exc: # pragma: no cover - defensive
logger.error("Remote prefill failed: %s", exc)
# ---- Direct-streaming ASGI app ----
async def __serve_build_asgi_app__(self):
"""Serve direct-streaming HTTP through P/D orchestration.
Start from the engine-native app (same as non-P/D direct streaming)
and re-point only ``/v1/chat/completions`` and ``/v1/completions`` at
this server's ``chat`` / ``completions``, which run remote prefill
then local decode. All other routes stay engine-native.
"""
app = await super().__serve_build_asgi_app__()
_strip_routes(app, "/v1/chat/completions")
_strip_routes(app, "/v1/completions")
@app.post("/v1/chat/completions")
async def _pd_chat(body: ChatCompletionRequest, request: Request):
body = _sanitize_chat_completion_request(body)
raw_info = RawRequestInfo.from_starlette_request(request)
return await _pd_http_response(await self.chat(body, raw_info))
@app.post("/v1/completions")
async def _pd_completions(body: CompletionRequest, request: Request):
raw_info = RawRequestInfo.from_starlette_request(request)
return await _pd_http_response(await self.completions(body, raw_info))
return app
# ---- Pre-warm ----
#
# KV transfer connectors (e.g. NIXL) require a handshake between each
# prefill and decode replica before real traffic can flow. Pre-warming
# sends a tiny dummy request through the full prefill->decode path for
# every prefill replica so that the connector establishes its connections
# eagerly at startup rather than on the first user request.
# Enable via: experimental_configs={"_prewarm_prefill_decode": True}
def _make_dummy_request(self, model_id: str) -> CompletionRequest:
"""Build the smallest valid completion request for pre-warm."""
return CompletionRequest(
model=model_id,
prompt=_PREWARM_PROMPT,
max_tokens=_PREWARM_MAX_TOKENS,
stream=False,
request_id=f"prewarm-{uuid.uuid4()}",
)
async def _maybe_prewarm(self) -> None:
"""Run one prefill->decode round-trip per P replica to complete
the connector handshake on both sides before traffic arrives."""
prewarm_enabled = getattr(
self, "_llm_config", None
) and self._llm_config.experimental_configs.get("_prewarm_prefill_decode")
if not prewarm_enabled:
return
logger.info("[PDDecodeServer] Starting pre-warm across all P replicas.")
backend = self._get_connector_backend()
if backend.requires_peer_binding:
# Peer-binding connectors (e.g. MoRIIO) shape a prefill request
# against a specific selected replica's metadata; a peerless
# broadcast prewarm cannot bind one. The connector handshake
# completes on the first real request instead.
logger.info(
"[PDDecodeServer] Skipping pre-warm: connector %s requires peer "
"binding (handshake completes on the first real request).",
type(backend).__name__,
)
return
model_id = self._llm_config.model_id
dummy = self._make_dummy_request(model_id)
prefill_req = backend.prepare_prefill_request(request=dummy, peer=None)
# Broadcast to every live P replica; retry until they are up.
kv_params_list: List[Any] = []
attempt = 0
while attempt < _PREWARM_MAX_RETRIES:
attempt += 1
try:
kv_params_list = await asyncio.get_event_loop().run_in_executor(
None,
lambda: broadcast(
self._prefill_handle,
method_name="prewarm_prefill",
args=[prefill_req],
),
)
break
except DeploymentUnavailableError:
logger.info(
"[PDDecodeServer] PrefillServer not available yet "
"(attempt %d/%d); retrying in %.0fs...",
attempt,
_PREWARM_MAX_RETRIES,
_PREWARM_RETRY_INTERVAL_S,
)
await asyncio.sleep(_PREWARM_RETRY_INTERVAL_S)
except Exception as exc:
logger.warning(
"[PDDecodeServer] broadcast() attempt %d/%d failed; "
"retrying in %.0fs...",
attempt,
_PREWARM_MAX_RETRIES,
_PREWARM_RETRY_INTERVAL_S,
exc_info=exc,
)
await asyncio.sleep(_PREWARM_RETRY_INTERVAL_S)
else:
raise RuntimeError(
f"[PDDecodeServer] Pre-warm failed after {_PREWARM_MAX_RETRIES} "
f"attempts ({_PREWARM_MAX_RETRIES * _PREWARM_RETRY_INTERVAL_S:.0f}s). "
f"PrefillServer may be permanently unavailable."
)
logger.info(
"[PDDecodeServer] broadcast() reached %d P replica(s); "
"driving local decode to complete the handshake.",
len(kv_params_list),
)
# Build one decode request per P replica result.
decode_reqs: List[CompletionRequest] = []
for idx, kv_params in enumerate(kv_params_list):
if not kv_params:
logger.warning(
"[PDDecodeServer] P replica %d returned empty kv_params; skipping.",
idx,
)
continue
req = dummy.model_copy(deep=True)
req.kv_transfer_params = kv_params
decode_reqs.append(req)
# Run all decode requests on the local engine concurrently to trigger
# the connector handshake on D side for each P replica.
async def _decode_one(req: CompletionRequest, idx: int) -> None:
async for _ in self.engine.completions(req, None):
pass
logger.info(
"[PDDecodeServer] Pre-warm handshake done for P replica %d.", idx
)
await asyncio.gather(*[_decode_one(r, i) for i, r in enumerate(decode_reqs)])
logger.info("[PDDecodeServer] Pre-warm complete — all P replicas registered.")
async def _drain_prefill(prefill_resp) -> Optional[ErrorResponse]:
"""Consume a concurrent-handoff prefill response to completion.
In concurrent (e.g. WRITE-mode) handoff the remote prefill produces no useful
tokens — it only needs to run so the connector pushes/registers the KV. We
drain it so the response is fully awaited before the ``choose_replica``
context (if any) exits. Returns an ``ErrorResponse`` if one is observed.
Handles both streaming (``DeploymentResponseGenerator``) and non-streaming
(single ``DeploymentResponse``) results.
"""
try:
async for chunk in prefill_resp:
if isinstance(chunk, ErrorResponse):
return chunk
except TypeError:
result = await prefill_resp
if isinstance(result, ErrorResponse):
return result
return None
# ---------------------------------------------------------------------------
# PDPrefillServer
# ---------------------------------------------------------------------------
class PDPrefillServer(LLMServer):
"""Prefill-side LLM server for P/D disaggregation.
This is a standard LLMServer with an additional ``prewarm_prefill``
method used during the pre-warm handshake.
"""
async def record_replica_metadata(self) -> Dict[str, Any]:
"""Publish this prefill replica's connector coordination metadata.
Read by the decode orchestrator via the replica-metadata hook
(``ReplicaSelection.replica_metadata``) so peer-binding connectors (e.g.
MoRIIO) can address the selected prefill replica. Returns ``{}`` for
connectors that publish nothing (the ``BaseConnectorBackend`` default).
Returns the metadata of the backend that engine init
(``setup_engine_backend``) created, ``setup()``-ed, and stored on this
server's ``_llm_config``. The replica-metadata hook is captured after
engine init, so for connector deployments the backend is present by
then; with no backend stored there is nothing to publish.
"""
backend = getattr(self._llm_config, "kv_connector_backend", None)
if backend is None:
return {}
return backend.replica_metadata()
async def prewarm_prefill(
self, prefill_request: CompletionRequest
) -> Optional[dict]:
"""Run one prefill pass and return kv_transfer_params as a dict.
Returns None on error.
"""
async for chunk in self.engine.completions(prefill_request, None):
if hasattr(chunk, "kv_transfer_params") and chunk.kv_transfer_params:
return chunk.kv_transfer_params
if isinstance(chunk, ErrorResponse):
logger.warning("[PDPrefillServer] prewarm_prefill got error: %s", chunk)
return None
return None
# ---------------------------------------------------------------------------
# PDDecodeServer
# ---------------------------------------------------------------------------
class PDDecodeServer(PDOrchestratorMixin, LLMServer):
"""Decode-side LLM server that orchestrates remote prefill.
This deployment owns a real engine (decode config) and holds a handle
to the prefill deployment. For chat / completions it runs remote
prefill first, then local decode.
"""
async def __init__(
self,
llm_config: LLMConfig,
*,
prefill_server: DeploymentHandle,
engine_cls=None,
model_downloader=None,
):
self._prefill_handle = prefill_server.options(stream=True)
await super().__init__(
llm_config,
engine_cls=engine_cls,
model_downloader=model_downloader,
)
# Active only if enabled and the renderer wrap installs. The `and`
# short-circuits so install() is not called when disabled.
self._pd_tokenize_once = (
bool(self._llm_config.experimental_configs.get("pd_tokenize_once"))
and _install_tokenize_once()
)
await self._maybe_prewarm()
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
return self._pd_handle_request(request, raw_request_info)
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
return self._pd_handle_request(request, raw_request_info)
# ---------------------------------------------------------------------------
# DP + PD combined servers
# ---------------------------------------------------------------------------
class DPPDPrefillServer(PDPrefillServer, DPServer):
"""PDPrefillServer with data-parallel gang scheduling.
MRO: DPPDPrefillServer -> PDPrefillServer -> DPServer -> LLMServer
- get_deployment_options comes from DPServer (adds gang scheduling).
- __init__ falls through to DPServer (DP master info, bundle indices)
then LLMServer (engine setup).
"""
pass
class DPPDDecodeServer(PDDecodeServer, DPServer):
"""PDDecodeServer with data-parallel gang scheduling.
MRO: DPPDDecodeServer -> PDDecodeServer -> PDOrchestratorMixin
-> DPServer -> LLMServer
- get_deployment_options comes from DPServer (adds gang scheduling).
- __init__ from PDDecodeServer sets _prefill_handle, then super().__init__
flows through DPServer (DP setup) then LLMServer (engine setup).
"""
pass
# ---------------------------------------------------------------------------
# Deprecated: PDProxyServer
# TODO(Kourosh): Deprecate, remove in Ray 2.58.
# ---------------------------------------------------------------------------
class PDProxyServer(LLMServerProtocol):
"""Proxy between P/D LLM servers.
.. deprecated::
``PDProxyServer`` is deprecated. Use ``PDDecodeServer`` instead.
This class will be removed in a future release.
"""
async def __init__(
self,
prefill_server: DeploymentHandle,
decode_server: DeploymentHandle,
):
warnings.warn(
"PDProxyServer is deprecated and will be removed in Ray 2.58. "
"Use PDDecodeServer (decode orchestrator) and PDPrefillServer instead.",
DeprecationWarning,
stacklevel=2,
)
self._llm_config = await prefill_server.llm_config.remote()
self.prefill_server = prefill_server.options(stream=True)
self.decode_server = decode_server.options(stream=True)
async def start(self) -> None:
pass
async def check_health(self) -> None:
pass
async def reset_prefix_cache(self) -> None:
raise NotImplementedError(
"reset_prefix_cache is not supported for P/D disaggregation"
)
async def start_profile(self) -> None:
raise NotImplementedError(
"start_profile is not supported for P/D disaggregation"
)
async def stop_profile(self) -> None:
raise NotImplementedError(
"stop_profile is not supported for P/D disaggregation"
)
async def llm_config(self) -> Optional[LLMConfig]:
return self._llm_config
def _prepare_prefill_request(self, request: RequestType) -> RequestType:
assert (
getattr(request, "kv_transfer_params", None) is None
), "kv_transfer_params should be empty before proxy"
prefill_request = request.model_copy(deep=True)
prefill_request.kv_transfer_params = {
"do_remote_decode": True,
"do_remote_prefill": False,
"remote_engine_id": None,
"remote_block_ids": None,
"remote_host": None,
"remote_port": None,
}
prefill_request.max_tokens = 1
prefill_request.stream = False
return prefill_request
def _prepare_decode_request(
self,
request: RequestType,
prefill_chunk: Union[ChatCompletionResponse, CompletionResponse],
) -> RequestType:
decode_request = request.model_copy(deep=True)
decode_request.kv_transfer_params = prefill_chunk.kv_transfer_params
return decode_request
def _maybe_add_request_id_to_request(
self,
request: Union[ChatCompletionRequest, CompletionRequest],
) -> None:
request_id = get_serve_request_id()
if request_id:
request.request_id = request_id
async def _handle_request(
self,
request: RequestType,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[
Union[str, ChatCompletionResponse, CompletionResponse, ErrorResponse], None
]:
self._maybe_add_request_id_to_request(request)
if isinstance(request, ChatCompletionRequest):
method = "chat"
elif isinstance(request, CompletionRequest):
method = "completions"
else:
raise ValueError(f"Unsupported request type: {type(request)}")
prefill_request = self._prepare_prefill_request(request)
prefill_gen = getattr(self.prefill_server, method).remote(
prefill_request, raw_request_info
)
prefill_chunk = await prefill_gen.__anext__()
if isinstance(prefill_chunk, ErrorResponse):
logger.error(f"Prefill returned error: {prefill_chunk}")
yield prefill_chunk
return
decode_request = self._prepare_decode_request(request, prefill_chunk)
decode_gen = getattr(self.decode_server, method).remote(
decode_request, raw_request_info
)
async for chunk in decode_gen:
yield chunk
async def chat(
self,
request: ChatCompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
return self._handle_request(request, raw_request_info)
async def completions(
self,
request: CompletionRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
return self._handle_request(request, raw_request_info)
async def embeddings(
self,
request: EmbeddingRequest,
raw_request_info: Optional[RawRequestInfo] = None,
) -> AsyncGenerator[EmbeddingResponse, None]:
raise NotImplementedError("Embedding is not supported for P/D disaggregation")
@classmethod
def get_deployment_options(
cls, prefill_config: "LLMConfig", decode_config: "LLMConfig"
) -> Dict[str, Any]:
return DEFAULT_PD_PROXY_SERVER_OPTIONS
@@ -0,0 +1,111 @@
import asyncio
from typing import AsyncGenerator, Generic, Iterable, List, Optional, TypeVar
from ray.llm._internal.serve.constants import (
MODEL_RESPONSE_BATCH_TIMEOUT_MS,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
T = TypeVar("T")
class Batcher(Generic[T]):
"""This class batches multiple responses from a generator into a list of
single responses, at some time interval.
Args:
generator: the async generator that this class pulls responses
from.
interval_ms: the interval at which this class yields the current batch.
If None, this class will batch all responses from the generator
together and yield the entire batch once.
"""
def __init__(
self,
generator: AsyncGenerator[T, None],
interval_ms: Optional[float] = MODEL_RESPONSE_BATCH_TIMEOUT_MS,
):
self.generator = generator
self.queue: asyncio.Queue = asyncio.Queue()
if interval_ms is None:
self.interval_s = None
else:
self.interval_s = interval_ms / 1000
if interval_ms == 0:
return
self.done_event: asyncio.Event = asyncio.Event()
# We are okay with this task getting cancelled (to propagate cancellations)
self.read_task = asyncio.create_task(self.read())
def _merge_results(self, results: List[T]) -> Iterable[T]:
return results
async def stream(self) -> AsyncGenerator[Iterable[T], None]:
"""Drain from the queue every interval_ms and yield the merged results"""
if self.interval_s == 0:
async for item in self.generator:
yield [item]
return
try:
while True:
# Wait for the interval or until we finish, whichever is faster.
# We use an event to avoid asyncio.wait_for cancelling the real task on timeout.
try:
if self.interval_s is None:
await self.done_event.wait()
else:
await asyncio.wait_for(
self.done_event.wait(), timeout=self.interval_s
)
except asyncio.TimeoutError:
pass
# Get all elements from the queue
results, is_done = self.check_done_and_drain()
# If there are results, merge and yield them
if results:
output = self._merge_results(results)
yield output
# If the read task is done, exit the stream task
if is_done:
# Raise exception, if any
self.read_task.result()
break
finally:
# If the stream task is done, make sure to exit the read task
if not self.read_task.done():
self.read_task.cancel()
def check_done_and_drain(self):
results = self.drain_queue()
return results, self.read_task.done()
async def read(self):
"""Read from the generator and put into the queue in a tight loop"""
try:
async for x in self.generator:
self.queue.put_nowait(x)
finally:
self.done_event.set()
def drain_queue(self):
"""Drain all results currently in the queue"""
results = []
try:
while True:
results.append(self.queue.get_nowait())
except asyncio.QueueEmpty:
pass
return results
@@ -0,0 +1,153 @@
import pickle
import time
import uuid
from typing import Any, Callable, Dict, List, Optional, Union
import ray
from ray.llm._internal.serve.observability.logging import get_logger
from ray.serve._private.common import RequestMetadata
from ray.serve.handle import DeploymentHandle
logger = get_logger(__name__)
# Timeout in seconds for waiting for deployment replicas to be populated
BROADCAST_REPLICA_POPULATION_TIMEOUT_S = 30
def broadcast(
handle: DeploymentHandle,
method_name: str,
args: Union[Any, Callable[[Any], Any]] = None,
kwargs: Union[Dict[str, Any], Callable[[Any], Dict[str, Any]]] = None,
combine: Optional[Callable[[List[Any]], Any]] = None,
) -> Any:
"""
Broadcasts a method call to all replicas of the given handle.
This is useful for broadcasting a control plane message such as kv-cache
reset or weight update to all replicas of the given handle.
NOTE: This API is experimental and may later be promoted to a public API in
Ray Serve directly. For now, it is only available in Ray LLM and is
intended to enable control plane operations during RL training which is
required when orchestrating trianing and inference loops.
Args:
handle: The DeploymentHandle to broadcast to.
method_name: The name of the method to call on the deployment.
args: The arguments to pass to the method. Can be a list/tuple of args,
or a callable that takes the replica object and returns args.
kwargs: The keyword arguments to pass to the method. Can be a dict,
or a callable that takes the replica object and returns kwargs.
combine: An optional callable that takes the list of results from all
replicas and returns an aggregated result. If not provided, returns
the list of results. The default combine function is to return the
list of results.
Returns:
The result of the method call to all replicas. If combine is provided,
returns the aggregated result. Otherwise, returns the list of results.
"""
if args is None:
args = ()
if kwargs is None:
kwargs = {}
if not handle.is_initialized:
# If the handle is not initialized, we initialize it here.
# We enforce running the router in a separate loop to ensure it can
# update its replica set asynchronously while we might be blocking or
# waiting.
handle._init(_run_router_in_separate_loop=True)
router = handle._router
if router is None:
raise RuntimeError("DeploymentHandle router is None.")
# Wait for both the replica set AND the request router to be populated.
# `running_replicas_populated()` flips when DEPLOYMENT_TARGETS long-poll
# arrives; `request_router` becomes non-None only after DEPLOYMENT_CONFIG
# long-poll arrives and sets `_request_router_class`. These are independent
# long-polls, so polling only the former races with the latter.
#
# In normal request flow this is hidden because `assign_request` awaits
# `_request_router_initialized` before routing — but `broadcast()` bypasses
# `assign_request` and pokes `_replica_id_set` directly, so it has to
# synchronize itself.
def _get_request_router():
if hasattr(router, "_asyncio_router"):
return router._asyncio_router.request_router
if hasattr(router, "request_router"):
return router.request_router
return None
start_time = time.time()
while not handle.running_replicas_populated() or _get_request_router() is None:
if time.time() - start_time > BROADCAST_REPLICA_POPULATION_TIMEOUT_S:
raise TimeoutError(
"Timed out waiting for deployment router/replicas to initialize."
)
time.sleep(0.1)
request_router = _get_request_router()
replica_set = request_router._replica_id_set
# Execute calls
futures = []
# We copy the set to avoid modification during iteration if that happens
replicas = list(replica_set)
for replica in replicas:
actor_name = replica.to_full_id_str()
try:
actor_handle = ray.get_actor(actor_name, namespace="serve")
except ValueError:
# Actor might be dead or not found
continue
# Prepare args
call_args = args
call_kwargs = kwargs
if callable(args):
call_args = args(replica)
if callable(kwargs):
call_kwargs = kwargs(replica)
if not isinstance(call_args, (list, tuple)):
raise ValueError(f"args must be a list or tuple, got {type(call_args)}")
if not isinstance(call_kwargs, dict):
# Fallback if callable returned something else or initial was not dict
# But initial default is dict.
if call_kwargs is None:
call_kwargs = {}
else:
raise ValueError(f"kwargs must be a dict, got {type(call_kwargs)}")
# Prepare Metadata
request_id = f"broadcast-{uuid.uuid4()}"
dummy_rm = RequestMetadata(
request_id=request_id,
internal_request_id=request_id,
call_method=method_name,
)
pickled_rm = pickle.dumps(dummy_rm)
# Fire remote call
# We collect futures to wait for them
futures.append(
actor_handle.handle_request.remote(pickled_rm, *call_args, **call_kwargs)
)
# Wait for all calls to complete
results = []
if futures:
results = ray.get(futures)
if combine:
return combine(results)
return results
@@ -0,0 +1,237 @@
"""
Serve-specific LoRA utilities that use generic abstractions from lora_utils.py.
This module provides serve-specific functionality while using the generic
LoRA abstractions from common/lora_utils.py. This ensures clean separation
between generic and serve-specific concerns.
"""
import asyncio
import json
import os
from typing import Any, Dict, Optional
from fastapi import HTTPException
from ray.llm._internal.common.constants import LORA_ADAPTER_CONFIG_NAME
from ray.llm._internal.common.models import global_id_manager, make_async
from ray.llm._internal.common.utils.cloud_utils import (
LoraMirrorConfig,
)
from ray.llm._internal.common.utils.lora_utils import (
CLOUD_OBJECT_MISSING,
clean_model_id,
clear_directory,
get_base_model_id,
get_lora_id,
get_object_from_cloud,
retry_with_exponential_backoff,
sync_files_with_lock,
)
from ray.llm._internal.serve.core.configs.llm_config import (
DiskMultiplexConfig,
LLMConfig,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
async def get_lora_finetuned_context_length(bucket_uri: str) -> Optional[int]:
"""Gets the sequence length used to tune the LoRA adapter.
Return: Returns the max sequence length for the adapter, if it exists.
Raises: HTTPException if the LoRA adapter config file isn't available
in the cloud storage repository.
"""
if bucket_uri.endswith("/"):
bucket_uri = bucket_uri.rstrip("/")
object_uri = f"{bucket_uri}/{LORA_ADAPTER_CONFIG_NAME}"
object_str_or_missing_message = await get_object_from_cloud(object_uri)
if object_str_or_missing_message is CLOUD_OBJECT_MISSING:
raise HTTPException(
404,
f"Unable to find LoRA adapter config file "
f'"{LORA_ADAPTER_CONFIG_NAME}" in folder {bucket_uri}. '
"Check that the file exists and that you have read permissions.",
)
else:
adapter_config_str = object_str_or_missing_message
adapter_config = json.loads(adapter_config_str)
return adapter_config.get("max_length")
async def download_multiplex_config_info(
model_id: str, base_path: str
) -> tuple[str, Optional[int]]:
"""Downloads info needed to create a multiplex config.
Downloads objects using cloud storage provider APIs.
Returns: 2-tuple containing
1. A bucket_uri for the bucket containing LoRA weights and config.
2. The maximum LoRA sequence length.
Raises: HTTPException if the LoRA adapter config file isn't available
in the cloud storage repository.
"""
bucket_uri = f"{base_path}/{model_id}"
ft_context_length = await get_lora_finetuned_context_length(bucket_uri)
return bucket_uri, ft_context_length
async def get_lora_model_metadata(model_id: str, base_path: str) -> Dict[str, Any]:
"""Get the lora model metadata for a given model id and base path.
Args:
model_id: A lora model id of the form ``base_model_id:suffix:id``.
base_path: The cloud storage path under which LoRA adapters are stored
(typically ``llm_config.lora_config.dynamic_lora_loading_path``).
Returns:
A dict with keys ``model_id``, ``base_model_id``,
``max_request_context_length``, and ``bucket_uri``.
"""
base_model_id = get_base_model_id(model_id)
lora_id = get_lora_id(model_id)
# Examples of the variables:
# model_id: "meta-llama/Meta-Llama-3.1-8B-Instruct:my_suffix:aBc1234"
# base_path: "s3://ray-llama-weights"
# bucket_uri: "s3://ray-llama-weights/my_suffix:aBc1234"
(
bucket_uri,
ft_context_length,
) = await download_multiplex_config_info(lora_id, base_path)
return {
"model_id": model_id,
"base_model_id": base_model_id,
"max_request_context_length": ft_context_length,
# Note (genesu): `bucket_uri` affects where the lora weights are downloaded
# from remote location.
"bucket_uri": bucket_uri,
}
async def get_lora_mirror_config(
model_id: str,
llm_config: LLMConfig,
) -> LoraMirrorConfig:
"""Get LoRA mirror configuration for serve-specific LLM config."""
metadata = await get_lora_model_metadata(
model_id, llm_config.lora_config.dynamic_lora_loading_path
)
return LoraMirrorConfig(
lora_model_id=model_id,
bucket_uri=metadata["bucket_uri"],
max_total_tokens=metadata["max_request_context_length"],
sync_args=None,
)
class LoraModelLoader:
"""Download LoRA weights from remote storage and manage disk cache.
This class is serve-specific as it depends on DiskMultiplexConfig and
other serve-specific concepts.
"""
def __init__(
self,
lora_root: Optional[str] = None,
download_timeout_s: Optional[float] = None,
max_tries: int = 1,
):
self.lora_root = lora_root or "/tmp/ray/llm/lora/cache"
self.disk_cache: Dict[str, DiskMultiplexConfig] = {}
self.active_syncing_tasks: Dict[str, asyncio.Task[DiskMultiplexConfig]] = {}
if download_timeout_s is not None and download_timeout_s <= 0:
raise ValueError(
f"download_timeout_s must be None or >0, got {download_timeout_s}"
)
self.download_timeout_s = download_timeout_s
if max_tries < 1:
raise ValueError(f"max_tries must be >=1, got {max_tries}")
self.max_tries = max_tries
async def load_model_from_config(
self, lora_model_id: str, llm_config
) -> DiskMultiplexConfig:
"""Load a LoRA model by first fetching its mirror config from S3."""
lora_mirror_config = await get_lora_mirror_config(lora_model_id, llm_config)
return await self.load_model(lora_model_id, lora_mirror_config)
async def load_model(
self, lora_model_id: str, lora_mirror_config: LoraMirrorConfig
) -> DiskMultiplexConfig:
"""Load a LoRA model."""
if lora_model_id in self.disk_cache:
return self.disk_cache[lora_model_id]
if lora_model_id not in self.active_syncing_tasks:
task = asyncio.create_task(self._load_model_async(lora_mirror_config))
task.add_done_callback(
lambda result: self.active_syncing_tasks.pop(lora_model_id, None)
)
self.active_syncing_tasks[lora_model_id] = task
else:
task = self.active_syncing_tasks[lora_model_id]
disk_config = await asyncio.shield(task)
self.disk_cache[lora_model_id] = disk_config
return disk_config
async def _load_model_async(
self, lora_mirror_config: LoraMirrorConfig
) -> DiskMultiplexConfig:
return await self._load_model(lora_mirror_config)
@make_async
def _load_model(self, lora_mirror_config: LoraMirrorConfig) -> DiskMultiplexConfig:
return self._load_model_sync(lora_mirror_config)
@make_async
def clear_cache(self):
"""Clear the disk cache."""
clear_directory(self.lora_root)
def _model_dir_path(self, model_id: str) -> str:
"""Construct the path for the lora weight."""
lora_id = get_lora_id(clean_model_id(model_id))
path = os.path.join(self.lora_root, lora_id)
os.makedirs(path, exist_ok=True)
return path
def _download_lora(self, lora_mirror_config: LoraMirrorConfig) -> str:
"""Download LoRA weights using generic download primitives."""
model_local_path = self._model_dir_path(lora_mirror_config.lora_model_id)
sync_files_with_lock(
lora_mirror_config.bucket_uri,
model_local_path,
timeout=self.download_timeout_s,
)
return model_local_path
def _load_model_sync(
self, lora_mirror_config: LoraMirrorConfig
) -> DiskMultiplexConfig:
"""Load a model from the given mirror configuration."""
download_with_retries = retry_with_exponential_backoff(
max_tries=self.max_tries,
exception_to_check=Exception,
)(lambda config: self._download_lora(config))
local_path = download_with_retries(lora_mirror_config)
return DiskMultiplexConfig.model_validate(
{
"model_id": lora_mirror_config.lora_model_id,
"max_total_tokens": lora_mirror_config.max_total_tokens,
"local_path": local_path,
"lora_assigned_int_id": global_id_manager.next(),
}
)
@@ -0,0 +1,75 @@
import asyncio
from typing import Optional
import ray
from ray.llm._internal.common.utils.download_utils import (
download_model_files,
)
from ray.llm._internal.common.utils.import_utils import try_import
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.observability.logging import get_logger
torch = try_import("torch")
transformers = try_import("transformers")
logger = get_logger(__name__)
def initialize_remote_node(llm_config: LLMConfig) -> Optional[str]:
callback = llm_config.get_or_create_callback()
engine_config = llm_config.get_engine_config()
local_path = download_model_files(
model_id=engine_config.actual_hf_model_id,
mirror_config=engine_config.mirror_config,
download_model=callback.ctx.worker_node_download_model,
download_extra_files=True,
callback=callback,
)
# Validate that the binary exists
if local_path and local_path != engine_config.actual_hf_model_id:
engine_config.hf_model_id = local_path
return local_path
async def initialize_node(llm_config: LLMConfig):
"""Implements node initialization for LLM engines.
Downloads model, tokenizer, and extra files as necessary.
"""
# Get callback instance (if configured) with context information
callback = llm_config.get_or_create_callback()
ctx = callback.ctx
pg_table = ray.util.placement_group_table(ctx.placement_group)
node_set = set(pg_table["bundles_to_node_id"].values())
download_tasks = []
for node_id in node_set:
node_affinity_strategy = (
ray.util.scheduling_strategies.NodeAffinitySchedulingStrategy(
node_id=node_id,
soft=False,
)
)
download_tasks.append(
ray.remote(initialize_remote_node).options(
num_cpus=0,
scheduling_strategy=node_affinity_strategy,
runtime_env=ctx.runtime_env,
)
)
logger.info("Running tasks to download model files on worker nodes")
paths = await asyncio.gather(
*[download_task.remote(llm_config) for download_task in download_tasks]
)
# assume that all paths are the same
assert paths, "No paths returned from download_model_files"
assert (
len(set(paths)) == 1
), "Paths returned from download_model_files are not the same"
llm_config.get_engine_config().hf_model_id = paths[0]
@@ -0,0 +1,68 @@
"""Placement group utilities for Ray LLM Serve."""
from collections import defaultdict
from typing import Dict, List, Optional
from ray.serve._private.utils import get_head_node_id
from ray.util.placement_group import PlacementGroup, placement_group_table
def _sort_bundle_indices_by_node(
bundles_to_node_id: Dict[int, str],
driver_node_id: str,
) -> List[int]:
"""Sort bundle indices so that same-node bundles are adjacent, driver node first.
Args:
bundles_to_node_id: Mapping from bundle index to node ID.
driver_node_id: The node ID of the driver node.
Returns:
List of bundle indices sorted with driver node bundles first,
then remaining nodes in deterministic order.
"""
node_to_bundles: Dict[str, List[int]] = defaultdict(list)
# bundle_idx is already in ascending order: created sequentially during
# placement group creation and preserved by the GCS protobuf.
for bundle_idx, node_id in bundles_to_node_id.items():
node_to_bundles[node_id].append(bundle_idx)
result: List[int] = []
if driver_node_id in node_to_bundles:
result.extend(node_to_bundles.pop(driver_node_id))
for node_id in sorted(node_to_bundles.keys()):
result.extend(node_to_bundles[node_id])
return result
def get_bundle_indices_sorted_by_node(
pg: PlacementGroup,
driver_node_id: Optional[str] = None,
) -> List[int]:
"""Return bundle indices sorted such that same-node bundles are adjacent, driver node first.
When a placement group is provisioned, adjacent bundle indices don't
necessarily map to the same physical node. This utility reorders bundle
indices so that bundles on the same node are grouped together.
The driver node's bundles come first so that global rank 0 (which hosts the
distributed rendezvous store) is co-located with the driver.
Args:
pg: A ready placement group.
driver_node_id: Node ID whose bundles should be ordered first. Callers
should pass the node that advertises the distributed master address
so that global rank 0 is co-located with it. Defaults to the cluster
head node, which may not own any of the placement group's bundles
(e.g. a CPU-only head node in a GPU cluster), in which case rank 0
falls back to the lexicographically-first node.
Returns:
List of bundle indices sorted such that same-node bundles are adjacent, driver node first.
"""
table = placement_group_table(pg)
bundles_to_node_id = table["bundles_to_node_id"]
if driver_node_id is None:
driver_node_id = get_head_node_id()
return _sort_bundle_indices_by_node(bundles_to_node_id, driver_node_id)
@@ -0,0 +1,328 @@
"""Generic registry for LLM serving components using Ray's internal KV store.
This module provides a reusable registry mechanism that enables components to be
registered in the driver process and accessed across all Ray processes in the cluster,
including Ray Serve child processes.
Similar to RLlib/Tune's registry but with a fixed global prefix for cross-job access.
"""
import importlib
from typing import Any, Callable
import ray._private.worker as worker
import ray.cloudpickle as pickle
from ray.experimental.internal_kv import (
_internal_kv_del,
_internal_kv_exists,
_internal_kv_get,
_internal_kv_initialized,
_internal_kv_put,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
# Fixed prefix for cross-job accessibility (Serve deployments run in different jobs)
_SERVE_REGISTRY_PREFIX = "serve_global"
def _make_key(category: str, name: str) -> bytes:
"""Generate a binary key for the KV store.
Args:
category: The component category (e.g., "kv_connector_backend")
name: The component name
Returns:
The key to use for storing the value
"""
return (
b"LLMServeRegistry:"
+ _SERVE_REGISTRY_PREFIX.encode("ascii")
+ b":"
+ category.encode("ascii")
+ b"/"
+ name.encode("ascii")
)
def _create_loader(value: Any) -> Callable[[], Any]:
"""Create a loader callable for a value.
Handles both direct objects/classes and string paths for lazy loading.
Args:
value: Either:
- A class, object, or callable (returns lambda: value)
- A string in format "module_path:class_name" (creates import loader)
Returns:
A callable that returns the value when called
Raises:
ValueError: If value is a string but doesn't have the correct format
"""
if isinstance(value, str):
if ":" not in value:
raise ValueError(
f"Invalid format for string value: '{value}'. "
f"Expected format: 'module_path:class_name' or a class/object."
)
module_path, class_name = value.rsplit(":", 1)
# Create a loader callable that imports on demand
def loader():
module = importlib.import_module(module_path)
return getattr(module, class_name)
return loader
else:
# For direct objects/classes, create a simple loader
return lambda: value
class ComponentRegistry:
"""Generic registry for LLM serving components using Ray's internal KV store.
This registry enables components to be registered in the driver process and
accessed across all Ray processes in the cluster, including Ray Serve child processes.
Similar to RLlib/Tune's registry but with a fixed global prefix for cross-job access.
**Usage Pattern:**
This registry is designed for a "register once, read many" pattern:
- Components are typically registered in the driver process before deployment
- Ray Serve replicas read from the KV store during initialization
- Once a component is resolved and cached in a process, subsequent `get()` calls return the cached value without checking the KV store for updates
Example:
# Create a registry for a component category
registry = ComponentRegistry("my_component")
# Register a component
registry.register("my_component", MyComponentClass)
# Get a registered component
component = registry.get("my_component")
# Check if registered
if registry.contains("my_component"):
...
"""
def __init__(self, category: str):
"""Initialize a registry for a specific component category.
Args:
category: The category name (e.g., "kv_connector_backend")
"""
self.category = category
self._loader_cache: dict[str, Callable[[], Any]] = {}
self._resolved_cache: dict[str, Any] = {}
self._pending: dict[str, bytes] = {}
def register(self, name: str, value: Any) -> None:
"""Register a component.
Args:
name: The name to register under
value: The component to register. Can be:
- A class, object, or callable (serialized directly)
- A string in format "module_path:class_name" (lazy-loaded via import)
Raises:
ValueError: If the component is already registered. Use unregister() first if you need to change the registration.
Examples:
# Register a class directly
registry.register("MyClass", MyClass)
# Register via module path (lazy loading)
registry.register("MyClass", "my.module:MyClass")
"""
# Prevent double registration to avoid cache inconsistencies
if self.contains(name):
raise ValueError(
f"{self.category} '{name}' is already registered. "
f"Use unregister() first if you need to change the registration."
)
# Create a loader callable (handles both direct values and string paths)
loader = _create_loader(value)
# Serialize the loader callable
serialized = pickle.dumps(loader)
# Store loader in cache
self._loader_cache[name] = loader
# Store in KV store if Ray is initialized, otherwise queue for later
if _internal_kv_initialized():
try:
key = _make_key(self.category, name)
_internal_kv_put(key, serialized, overwrite=True)
logger.debug(f"Registered {self.category} '{name}' in KV store")
except Exception as e:
logger.warning(
f"Failed to register {self.category} '{name}' in KV store: {e}",
exc_info=True,
)
self._pending[name] = serialized
else:
self._pending[name] = serialized
def get(self, name: str) -> Any:
"""Get a registered component.
Args:
name: The name of the component
Returns:
The registered component. If registered with a string path,
returns the imported class/object. If registered directly,
returns the original value.
Raises:
ValueError: If the component is not registered
"""
# Check resolved cache first.
if name in self._resolved_cache:
return self._resolved_cache[name]
loader = self._loader_cache.get(name)
# If not in local loader cache, try fetching from KV store.
if loader is None and _internal_kv_initialized():
try:
key = _make_key(self.category, name)
serialized = _internal_kv_get(key)
if serialized is not None:
loader = pickle.loads(serialized)
# Cache the loader for future gets.
self._loader_cache[name] = loader
logger.debug(f"Loaded {self.category} '{name}' from KV store")
except Exception as e:
logger.warning(
f"Failed to load {self.category} '{name}' from KV store: {e}",
exc_info=True,
)
if loader is not None:
value = loader()
self._resolved_cache[name] = value
return value
# Not found
raise ValueError(
f"{self.category} '{name}' not found. "
f"Registered: {list(self._loader_cache.keys())}"
)
def contains(self, name: str) -> bool:
"""Check if a component is registered.
Args:
name: The name to check
Returns:
True if registered, False otherwise
"""
if name in self._loader_cache:
return True
if _internal_kv_initialized():
try:
key = _make_key(self.category, name)
return _internal_kv_exists(key)
except Exception as e:
logger.warning(
f"Failed to check if {self.category} '{name}' exists in KV store: {e}",
exc_info=True,
)
return False
return False
def unregister(self, name: str) -> None:
"""Unregister a component.
Removes the component from local cache, pending registrations, and KV store.
Args:
name: The name of the component to unregister
"""
# Remove from local caches
if name in self._loader_cache:
del self._loader_cache[name]
if name in self._resolved_cache:
del self._resolved_cache[name]
# Remove from pending if present
if name in self._pending:
del self._pending[name]
# Remove from KV store if Ray is initialized
if _internal_kv_initialized():
try:
key = _make_key(self.category, name)
_internal_kv_del(key)
logger.debug(f"Unregistered {self.category} '{name}' from KV store")
except Exception as e:
logger.warning(
f"Failed to unregister {self.category} '{name}' from KV store: {e}",
exc_info=True,
)
def flush_pending(self) -> None:
"""Flush pending registrations to KV store.
This is called automatically when Ray initializes via _post_init_hooks.
"""
if not _internal_kv_initialized() or not self._pending:
return
for name, serialized in self._pending.items():
try:
key = _make_key(self.category, name)
_internal_kv_put(key, serialized, overwrite=True)
logger.debug(
f"Flushed pending registration for {self.category} '{name}'"
)
except Exception as e:
logger.warning(
f"Failed to flush {self.category} '{name}': {e}", exc_info=True
)
self._pending.clear()
# Global registry instances for different component categories
_registries: dict[str, ComponentRegistry] = {}
def get_registry(category: str) -> ComponentRegistry:
"""Get or create a registry for a component category.
Args:
category: The component category name
Returns:
The ComponentRegistry instance for this category
"""
if category not in _registries:
_registries[category] = ComponentRegistry(category)
return _registries[category]
def _flush_all_registries():
"""Flush all pending registrations to KV store.
This is registered as a Ray post-init hook to ensure registrations
made before Ray initialization are available across processes.
"""
for registry in _registries.values():
registry.flush_pending()
if _flush_all_registries not in worker._post_init_hooks:
worker._post_init_hooks.append(_flush_all_registries)
@@ -0,0 +1,218 @@
import asyncio
import threading
import time
import traceback
from functools import partial
from typing import Awaitable, Callable, TypeVar
from fastapi import HTTPException, status
from httpx import HTTPStatusError as HTTPXHTTPStatusError
from pydantic import ValidationError as PydanticValidationError
from ray import serve
from ray.llm._internal.common.errors import VLLM_FATAL_ERRORS
from ray.llm._internal.serve.constants import DEFAULT_FATAL_ERROR_COOLDOWN_S
from ray.llm._internal.serve.core.configs.openai_api_models import (
ErrorInfo,
ErrorResponse,
OpenAIHTTPException,
)
from ray.llm._internal.serve.observability.logging import get_logger
logger = get_logger(__name__)
T = TypeVar("T")
def _is_fatal_engine_error(e: Exception) -> bool:
"""Detect fatal engine errors via isinstance check."""
if not VLLM_FATAL_ERRORS:
return False
return isinstance(e, VLLM_FATAL_ERRORS)
class _FatalEngineErrorLogHandler:
"""Rate limits logging for fatal engine errors.
- First fatal error: logged with full traceback.
- Subsequent occurences within ``cooldown_s``: suppressed.
- Next fatal error after ``cooldown_s``: emits a summary with suppressed errors.
- Fatal error after ``2 * cooldown_s`` of quiet: logs full traceback again.
- Non-fatal errors: always logged, unaffected by rate limiting.
"""
def __init__(self, cooldown_s: float = DEFAULT_FATAL_ERROR_COOLDOWN_S):
self._cooldown_s = cooldown_s
self._first_logged = False
self._suppressed_count = 0
self._last_summary_time = 0.0
self._lock = threading.Lock()
def log(
self,
e: Exception,
request_id: str,
status_code: int,
) -> None:
"""Log the error, rate limiting fatal engine errors."""
is_fatal = _is_fatal_engine_error(e)
if not is_fatal:
log_fn = logger.error if status_code >= 500 else logger.warning
log_fn(
f"Encountered failure while handling request {request_id}",
exc_info=e,
extra={"ray_serve_extra_fields": {"status_code": status_code}},
)
return
with self._lock:
now = time.monotonic()
# If enough quiet time has passed, treat this as a new failure
# event. The suppressed count is intentionally dropped since the
# original fatal error's full traceback was already emitted.
if (
self._first_logged
and (now - self._last_summary_time) >= 2 * self._cooldown_s
):
self._first_logged = False
self._suppressed_count = 0
if not self._first_logged:
self._first_logged = True
self._last_summary_time = now
logger.error(
"Encountered failure while handling request %s",
request_id,
exc_info=e,
extra={"ray_serve_extra_fields": {"status_code": status_code}},
)
return
self._suppressed_count += 1
elapsed = now - self._last_summary_time
if elapsed >= self._cooldown_s:
logger.error(
"Suppressed %d fatal engine error(s) in the last %.0fs. "
"Engine is dead, awaiting replica restart.",
self._suppressed_count,
elapsed,
)
self._suppressed_count = 0
self._last_summary_time = now
_fatal_error_log_handler = _FatalEngineErrorLogHandler()
def make_async(_func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
"""Take a blocking function, and run it on in an executor thread.
This function prevents the blocking function from blocking the asyncio event loop.
The code in this function needs to be thread safe.
"""
def _async_wrapper(*args, **kwargs) -> asyncio.Future:
loop = asyncio.get_event_loop()
func = partial(_func, *args, **kwargs)
return loop.run_in_executor(executor=None, func=func)
return _async_wrapper
def extract_message_from_exception(e: Exception) -> str:
# If the exception is a Ray exception, we need to dig through the text to get just
# the exception message without the stack trace
# This also works for normal exceptions (we will just return everything from
# format_exception_only in that case)
message_lines = traceback.format_exception_only(type(e), e)[-1].strip().split("\n")
message = ""
# The stack trace lines will be prefixed with spaces, so we need to start from the bottom
# and stop at the last line before a line with a space
found_last_line_before_stack_trace = False
for line in reversed(message_lines):
if not line.startswith(" "):
found_last_line_before_stack_trace = True
if found_last_line_before_stack_trace and line.startswith(" "):
break
message = line + "\n" + message
message = message.strip()
return message
def _extract_message(e):
if isinstance(e, OpenAIHTTPException) and e.internal_message is not None:
internal_message = e.internal_message
else:
internal_message = extract_message_from_exception(e)
if isinstance(e, HTTPException):
message = e.detail
elif isinstance(e, OpenAIHTTPException):
message = e.message
else:
message = internal_message
return internal_message, message
def get_response_for_error(
e: Exception,
request_id: str,
) -> ErrorResponse:
if isinstance(e, HTTPException):
status_code = e.status_code
elif isinstance(e, OpenAIHTTPException):
status_code = e.status_code
elif isinstance(e, PydanticValidationError):
status_code = 400
elif isinstance(e, HTTPXHTTPStatusError):
status_code = e.response.status_code
else:
# Try to get the status code attribute from exception,
# if not present, fallback to generic 500
status_code = int(
getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
)
_fatal_error_log_handler.log(e, request_id, status_code)
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
internal_message = message = "Internal Server Error"
exc_type = "InternalServerError"
else:
internal_message, message = _extract_message(e)
exc_type = e.__class__.__name__
# TODO make this more robust
if "(Request ID: " not in message:
message += f" (Request ID: {request_id})"
if "(Request ID: " not in internal_message:
internal_message += f" (Request ID: {request_id})"
error_info = ErrorInfo(
message=f"Message: {message}, Internal exception: {internal_message}, original exception: {str(e)}",
code=status_code,
type=exc_type,
)
error_response = ErrorResponse(error=error_info)
return error_response
def get_serve_request_id() -> str:
"""Get request id from serve request context."""
context = serve.context._serve_request_context.get()
if context is not None:
return context.request_id
return ""
def get_model_request_id(model: str):
return model + "-" + get_serve_request_id()
def replace_prefix(model: str) -> str:
"""Replace -- with / in model name to handle slashes within the URL path segment"""
return model.replace("--", "/")