chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from ray.llm._internal.batch.processor import (
|
||||
HttpRequestProcessorConfig,
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
ProcessorConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Processor",
|
||||
"ProcessorConfig",
|
||||
"ProcessorBuilder",
|
||||
"HttpRequestProcessorConfig",
|
||||
]
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Benchmark Ray Data LLM offline batch inference throughput.
|
||||
|
||||
Sample usage:
|
||||
python ray.llm._internal.batch.benchmark.benchmark_processor --mode vllm_engine --batch-size 64 --concurrency 1 --num-prompts 10000 --model facebook/opt-1.3b
|
||||
--tensor-parallel-size 2 --pipeline-parallel-size 2 --distributed-executor-backend ray
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from time import perf_counter, sleep
|
||||
|
||||
import ray
|
||||
from .dataset import ShareGPTDataset
|
||||
from ray import data, serve
|
||||
from ray.data.llm import (
|
||||
ChatTemplateStageConfig,
|
||||
DetokenizeStageConfig,
|
||||
ServeDeploymentProcessorConfig,
|
||||
TokenizerStageConfig,
|
||||
build_processor,
|
||||
vLLMEngineProcessorConfig,
|
||||
)
|
||||
from ray.serve.llm import (
|
||||
LLMConfig,
|
||||
ModelLoadingConfig,
|
||||
build_llm_deployment,
|
||||
)
|
||||
from ray.serve.llm.openai_api_models import CompletionRequest
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
"""Processor to benchmark."""
|
||||
|
||||
VLLM_ENGINE = "vllm_engine"
|
||||
SHARED_VLLM_ENGINE = "shared_vllm_engine"
|
||||
SERVE_DEPLOYMENT = "serve_deployment"
|
||||
SHARED_SERVE_DEPLOYMENT = "shared_serve_deployment"
|
||||
CLASSIFY = "classify"
|
||||
|
||||
|
||||
# Default sampling parameters -- ensure a fair comparison by omitting sampling-induced variance
|
||||
VLLM_SAMPLING_PARAMS = {
|
||||
"top_p": 1.0,
|
||||
"temperature": 1.0,
|
||||
"max_tokens": 100,
|
||||
"ignore_eos": True,
|
||||
}
|
||||
|
||||
# Default vLLM engine kwargs
|
||||
VLLM_ENGINE_KWARGS = {
|
||||
"max_num_batched_tokens": 4096,
|
||||
}
|
||||
|
||||
# Default tokenization kwargs for classification -- truncate to max_model_len.
|
||||
CLASSIFY_TOKENIZATION_KWARGS_DEFAULT = {"truncation": True, "max_length": 512}
|
||||
|
||||
|
||||
def build_vllm_engine_kwargs(**kwargs) -> dict:
|
||||
"""Build vLLM engine kwargs from command line arguments."""
|
||||
engine_kwargs = VLLM_ENGINE_KWARGS.copy()
|
||||
engine_kwargs.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
return engine_kwargs
|
||||
|
||||
|
||||
def _build_vllm_engine_config(
|
||||
model: str,
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
pipeline_parallel_size: int = None,
|
||||
tensor_parallel_size: int = None,
|
||||
distributed_executor_backend: str = None,
|
||||
task_type: str = None,
|
||||
max_model_len: int = None,
|
||||
) -> vLLMEngineProcessorConfig:
|
||||
"""Helper to create vLLMEngineProcessorConfig."""
|
||||
engine_kwargs = build_vllm_engine_kwargs(
|
||||
pipeline_parallel_size=pipeline_parallel_size,
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
distributed_executor_backend=distributed_executor_backend,
|
||||
)
|
||||
if max_model_len is not None:
|
||||
engine_kwargs["max_model_len"] = max_model_len
|
||||
|
||||
config = vLLMEngineProcessorConfig(
|
||||
model_source=model,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
chat_template_stage=False,
|
||||
tokenize_stage=False,
|
||||
detokenize_stage=False,
|
||||
engine_kwargs=engine_kwargs,
|
||||
)
|
||||
|
||||
if task_type is not None:
|
||||
config.task_type = task_type
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _build_serve_deployment_config(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
deployment_name: str = None,
|
||||
app_name: str = None,
|
||||
) -> ServeDeploymentProcessorConfig:
|
||||
"""Helper to create ServeDeploymentProcessorConfig."""
|
||||
return ServeDeploymentProcessorConfig(
|
||||
deployment_name=deployment_name,
|
||||
app_name=app_name,
|
||||
dtype_mapping={
|
||||
"CompletionRequest": CompletionRequest,
|
||||
},
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BenchmarkResult:
|
||||
mode: Mode
|
||||
batch_size: int
|
||||
concurrency: int
|
||||
samples: int
|
||||
elapsed_s: float
|
||||
|
||||
@property
|
||||
def throughput(self) -> float:
|
||||
return self.samples / self.elapsed_s if self.elapsed_s else 0.0
|
||||
|
||||
def show(self) -> None:
|
||||
print("\n" + "=" * 60)
|
||||
print(f"BENCHMARK - {self.mode}")
|
||||
print("=" * 60)
|
||||
print(f"Samples : {self.samples}")
|
||||
print(f"Batch size : {self.batch_size}")
|
||||
print(f"Concurrency : {self.concurrency}")
|
||||
print(f"Time (s) : {self.elapsed_s:.2f}")
|
||||
print(f"Throughput : {self.throughput:.2f} req/s")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def build_single_vllm_engine_processor(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
sampling_params: dict = VLLM_SAMPLING_PARAMS,
|
||||
pipeline_parallel_size: int = None,
|
||||
tensor_parallel_size: int = None,
|
||||
distributed_executor_backend: str = None,
|
||||
):
|
||||
"""Build vLLM engine processor for single-turn benchmark."""
|
||||
config = _build_vllm_engine_config(
|
||||
model,
|
||||
batch_size,
|
||||
concurrency,
|
||||
pipeline_parallel_size,
|
||||
tensor_parallel_size,
|
||||
distributed_executor_backend,
|
||||
)
|
||||
return build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
prompt=row["prompt"],
|
||||
sampling_params=sampling_params,
|
||||
),
|
||||
postprocess=lambda row: row,
|
||||
)
|
||||
|
||||
|
||||
def build_shared_vllm_engine_processor(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
sampling_params: dict = VLLM_SAMPLING_PARAMS,
|
||||
pipeline_parallel_size: int = None,
|
||||
tensor_parallel_size: int = None,
|
||||
distributed_executor_backend: str = None,
|
||||
):
|
||||
"""Build vLLM engine processor for multi-turn benchmark."""
|
||||
config = _build_vllm_engine_config(
|
||||
model,
|
||||
batch_size,
|
||||
concurrency,
|
||||
pipeline_parallel_size,
|
||||
tensor_parallel_size,
|
||||
distributed_executor_backend,
|
||||
)
|
||||
|
||||
processor1 = build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
prompt=row["prompt"],
|
||||
sampling_params=sampling_params,
|
||||
),
|
||||
postprocess=lambda row: {
|
||||
"prompt": row["generated_text"]
|
||||
if str(row.get("generated_text", "")).strip()
|
||||
else row["prompt"]
|
||||
},
|
||||
)
|
||||
|
||||
processor2 = build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
prompt=row["prompt"],
|
||||
sampling_params=sampling_params,
|
||||
),
|
||||
postprocess=lambda row: row,
|
||||
)
|
||||
|
||||
def multi_turn_processor(dataset):
|
||||
return processor2(processor1(dataset))
|
||||
|
||||
return multi_turn_processor
|
||||
|
||||
|
||||
def build_classify_processor(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
tokenization_kwargs: dict = CLASSIFY_TOKENIZATION_KWARGS_DEFAULT,
|
||||
max_model_len: int = 512,
|
||||
distributed_executor_backend: str = None,
|
||||
):
|
||||
"""Build vLLM engine processor for classification benchmark."""
|
||||
|
||||
engine_kwargs = VLLM_ENGINE_KWARGS.copy()
|
||||
if distributed_executor_backend is not None:
|
||||
engine_kwargs["distributed_executor_backend"] = distributed_executor_backend
|
||||
|
||||
# Truncate prompts to max_model_len to avoid errors on long inputs.
|
||||
tokenization_kwargs = {**tokenization_kwargs, "max_length": max_model_len}
|
||||
|
||||
config = vLLMEngineProcessorConfig(
|
||||
model_source=model,
|
||||
task_type="classify",
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
chat_template_stage=ChatTemplateStageConfig(enabled=False),
|
||||
tokenize_stage=TokenizerStageConfig(enabled=True),
|
||||
detokenize_stage=DetokenizeStageConfig(enabled=False),
|
||||
engine_kwargs=engine_kwargs,
|
||||
)
|
||||
return build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
prompt=row["prompt"],
|
||||
tokenization_kwargs=tokenization_kwargs,
|
||||
),
|
||||
postprocess=lambda row: {
|
||||
"probs": float(row["embeddings"][0])
|
||||
if row.get("embeddings") is not None and len(row["embeddings"]) > 0
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def setup_serve_deployment(model: str, concurrency: int) -> tuple[str, str]:
|
||||
"""Set up Ray Serve deployment for hosting the LLM model."""
|
||||
deployment_name = "benchmark_deployment"
|
||||
app_name = "benchmark_app"
|
||||
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=ModelLoadingConfig(
|
||||
model_id=model,
|
||||
model_source=model,
|
||||
),
|
||||
deployment_config=dict(
|
||||
name=deployment_name,
|
||||
# To fairly compare with vLLM engine processor, fix the number of replicas to the concurrency level
|
||||
autoscaling_config=dict(
|
||||
min_replicas=concurrency,
|
||||
max_replicas=concurrency,
|
||||
),
|
||||
),
|
||||
engine_kwargs=dict(
|
||||
enable_prefix_caching=True,
|
||||
enable_chunked_prefill=True,
|
||||
max_num_batched_tokens=4096,
|
||||
),
|
||||
)
|
||||
|
||||
override_serve_options = dict(name=deployment_name)
|
||||
llm_app = build_llm_deployment(
|
||||
llm_config, override_serve_options=override_serve_options
|
||||
)
|
||||
serve.run(llm_app, name=app_name)
|
||||
|
||||
print("Waiting for Serve deployment to be ready...")
|
||||
max_wait_time = 120 # seconds
|
||||
wait_time = 0
|
||||
while not _is_app_ready(app_name) and wait_time < max_wait_time:
|
||||
sleep(5)
|
||||
wait_time += 5
|
||||
|
||||
if wait_time >= max_wait_time:
|
||||
raise TimeoutError("Deployment failed to become ready within timeout")
|
||||
|
||||
print("Deployment is ready!")
|
||||
return deployment_name, app_name
|
||||
|
||||
|
||||
def _is_app_ready(app_name: str) -> bool:
|
||||
try:
|
||||
serve_status = serve.status()
|
||||
|
||||
if app_name in serve_status.applications:
|
||||
app_status = serve_status.applications[app_name]
|
||||
if app_status.status == "RUNNING":
|
||||
print(f"Application '{app_name}' is RUNNING.")
|
||||
return True
|
||||
else:
|
||||
print(f"Application '{app_name}' status: {app_status.status}")
|
||||
return False
|
||||
else:
|
||||
print(f"Application '{app_name}' not found in Serve status.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error checking app status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def build_single_serve_deployment_processor(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
sampling_params: dict = VLLM_SAMPLING_PARAMS,
|
||||
deployment_name: str = None,
|
||||
app_name: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Build Serve deployment processor for single-turn benchmark."""
|
||||
config = _build_serve_deployment_config(
|
||||
batch_size,
|
||||
concurrency,
|
||||
deployment_name,
|
||||
app_name,
|
||||
)
|
||||
return build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
method="completions",
|
||||
dtype="CompletionRequest",
|
||||
request_kwargs=dict(
|
||||
model=model,
|
||||
prompt=row["prompt"],
|
||||
**sampling_params,
|
||||
),
|
||||
),
|
||||
postprocess=lambda row: row,
|
||||
)
|
||||
|
||||
|
||||
def build_shared_serve_deployment_processor(
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
sampling_params: dict = VLLM_SAMPLING_PARAMS,
|
||||
deployment_name: str = None,
|
||||
app_name: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Build Serve deployment processor for multi-turn benchmark."""
|
||||
config = _build_serve_deployment_config(
|
||||
batch_size,
|
||||
concurrency,
|
||||
deployment_name,
|
||||
app_name,
|
||||
)
|
||||
|
||||
processor1 = build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
method="completions",
|
||||
dtype="CompletionRequest",
|
||||
request_kwargs=dict(
|
||||
model=model,
|
||||
prompt=row["prompt"],
|
||||
stream=False,
|
||||
),
|
||||
),
|
||||
postprocess=lambda row: {
|
||||
# Fall back to original prompt if generated text is empty
|
||||
"prompt": (
|
||||
row["choices"][0]["text"]
|
||||
if row.get("choices") and str(row["choices"][0].get("text", "")).strip()
|
||||
else row["prompt"]
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
processor2 = build_processor(
|
||||
config,
|
||||
preprocess=lambda row: dict(
|
||||
method="completions",
|
||||
dtype="CompletionRequest",
|
||||
request_kwargs=dict(
|
||||
model=model,
|
||||
prompt=row["prompt"],
|
||||
stream=False,
|
||||
),
|
||||
),
|
||||
postprocess=lambda row: row,
|
||||
)
|
||||
|
||||
def multi_turn_processor(dataset):
|
||||
return processor2(processor1(dataset))
|
||||
|
||||
return multi_turn_processor
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Benchmark execution
|
||||
# -----------------------------------------------------------------------------
|
||||
def run_processor(
|
||||
mode: Mode,
|
||||
dataset: data.Dataset,
|
||||
builder,
|
||||
**kwargs,
|
||||
) -> BenchmarkResult:
|
||||
processor = builder(**kwargs)
|
||||
|
||||
total_samples = dataset.count()
|
||||
|
||||
start = perf_counter()
|
||||
processor(dataset).materialize()
|
||||
elapsed = perf_counter() - start
|
||||
|
||||
return BenchmarkResult(
|
||||
mode=mode,
|
||||
batch_size=kwargs.get("batch_size"),
|
||||
concurrency=kwargs.get("concurrency"),
|
||||
samples=total_samples,
|
||||
elapsed_s=elapsed,
|
||||
)
|
||||
|
||||
|
||||
def benchmark(
|
||||
mode: Mode,
|
||||
dataset: data.Dataset,
|
||||
*,
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
model: str,
|
||||
sampling_params: dict = VLLM_SAMPLING_PARAMS,
|
||||
pipeline_parallel_size: int = None,
|
||||
tensor_parallel_size: int = None,
|
||||
distributed_executor_backend: str = None,
|
||||
) -> BenchmarkResult:
|
||||
mode_to_builder = {
|
||||
Mode.VLLM_ENGINE: build_single_vllm_engine_processor,
|
||||
Mode.SHARED_VLLM_ENGINE: build_shared_vllm_engine_processor,
|
||||
Mode.SERVE_DEPLOYMENT: build_single_serve_deployment_processor,
|
||||
Mode.SHARED_SERVE_DEPLOYMENT: build_shared_serve_deployment_processor,
|
||||
Mode.CLASSIFY: build_classify_processor,
|
||||
}
|
||||
|
||||
if mode not in mode_to_builder:
|
||||
raise ValueError(f"Unknown benchmark mode: {mode}")
|
||||
|
||||
builder = mode_to_builder[mode]
|
||||
|
||||
if mode in [Mode.SERVE_DEPLOYMENT, Mode.SHARED_SERVE_DEPLOYMENT]:
|
||||
deployment_name, app_name = setup_serve_deployment(model, concurrency)
|
||||
try:
|
||||
return run_processor(
|
||||
mode,
|
||||
dataset,
|
||||
builder,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
model=model,
|
||||
sampling_params=sampling_params,
|
||||
deployment_name=deployment_name,
|
||||
app_name=app_name,
|
||||
)
|
||||
finally:
|
||||
serve.delete(app_name)
|
||||
elif mode == Mode.CLASSIFY:
|
||||
return run_processor(
|
||||
mode,
|
||||
dataset,
|
||||
builder,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
model=model,
|
||||
distributed_executor_backend=distributed_executor_backend,
|
||||
)
|
||||
else:
|
||||
return run_processor(
|
||||
mode,
|
||||
dataset,
|
||||
builder,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
model=model,
|
||||
sampling_params=sampling_params,
|
||||
pipeline_parallel_size=pipeline_parallel_size,
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
distributed_executor_backend=distributed_executor_backend,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CLI
|
||||
# -----------------------------------------------------------------------------
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="vLLM throughput benchmark")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=[mode.value for mode in Mode],
|
||||
default=Mode.VLLM_ENGINE.value,
|
||||
help="Ray Data LLM processor to run benchmarks for",
|
||||
)
|
||||
# Dataset configuration
|
||||
parser.add_argument(
|
||||
"--dataset-path",
|
||||
type=str,
|
||||
default="/home/ubuntu/datasets/Code-feedback-sharegpt-renamed",
|
||||
help="Path to dataset on disk",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-prompts", type=int, default=1000, help="Number of prompts to process"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hf-dataset-id",
|
||||
type=str,
|
||||
default="Crystalcareai/Code-feedback-sharegpt-renamed",
|
||||
help="Hugging Face dataset ID to download",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hf-split",
|
||||
type=str,
|
||||
default="train",
|
||||
help="Hugging Face dataset split to load",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Random seed for dataset sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--truncate-prompt",
|
||||
type=int,
|
||||
default=512,
|
||||
help="Maximum prompt length",
|
||||
)
|
||||
# Engine configuration
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
required=True,
|
||||
help="LLM model to use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pipeline-parallel-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Pipeline parallel size for vLLM engine",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tensor-parallel-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Tensor parallel size for vLLM engine",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--distributed-executor-backend",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["ray", "mp", "uni"],
|
||||
help="Distributed executor backend for vLLM engine",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of tokens to generate per request (default: 100)",
|
||||
)
|
||||
# Ray Data worker configuration
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
required=True,
|
||||
help="Ray Data batch size for processing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--concurrency", type=int, required=True, help="Ray Data concurrency level"
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args(sys.argv[1:])
|
||||
|
||||
ray.init()
|
||||
try:
|
||||
dataset = ShareGPTDataset(
|
||||
dataset_path=args.dataset_path,
|
||||
seed=args.seed,
|
||||
hf_dataset_id=args.hf_dataset_id,
|
||||
hf_split=args.hf_split,
|
||||
truncate_prompt=args.truncate_prompt,
|
||||
)
|
||||
prompts = dataset.sample(args.num_prompts)
|
||||
|
||||
dataset = data.from_items(prompts)
|
||||
|
||||
sampling_params = VLLM_SAMPLING_PARAMS.copy()
|
||||
if args.max_tokens is not None:
|
||||
sampling_params["max_tokens"] = args.max_tokens
|
||||
|
||||
result = benchmark(
|
||||
Mode(args.mode),
|
||||
dataset,
|
||||
batch_size=args.batch_size,
|
||||
concurrency=args.concurrency,
|
||||
model=args.model,
|
||||
sampling_params=sampling_params,
|
||||
pipeline_parallel_size=args.pipeline_parallel_size,
|
||||
tensor_parallel_size=args.tensor_parallel_size,
|
||||
distributed_executor_backend=args.distributed_executor_backend,
|
||||
)
|
||||
result.show()
|
||||
finally:
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
This module defines a dataset framework for sampling benchmark requests.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from datasets import load_dataset, load_from_disk
|
||||
|
||||
|
||||
class BenchmarkDataset(ABC):
|
||||
DEFAULT_RANDOM_SEED = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset_path: Optional[str] = None,
|
||||
random_seed: int = DEFAULT_RANDOM_SEED,
|
||||
) -> None:
|
||||
"""
|
||||
Abstract base class for benchmark datasets.
|
||||
|
||||
All benchmark datasets should inherit from this class and implement
|
||||
the required abstract methods.
|
||||
|
||||
Args:
|
||||
dataset_path: The path to the dataset on disk.
|
||||
random_seed: The seed for the random number generator.
|
||||
"""
|
||||
self._dataset_path = dataset_path
|
||||
self._random_seed = random_seed
|
||||
|
||||
@abstractmethod
|
||||
def load_data(self) -> None:
|
||||
"""
|
||||
Load data from the dataset source into memory.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the method is not implemented in subclasses.
|
||||
"""
|
||||
raise NotImplementedError("load_data must be implemented in subclasses.")
|
||||
|
||||
@abstractmethod
|
||||
def sample(self, num_requests: int) -> List[Dict]:
|
||||
"""
|
||||
Sample prompts from the loaded dataset.
|
||||
|
||||
Args:
|
||||
num_requests: The number of prompts to sample from the dataset.
|
||||
|
||||
Returns:
|
||||
A list of sampled request dictionaries.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the method is not implemented in subclasses.
|
||||
"""
|
||||
raise NotImplementedError("sample must be implemented in subclasses.")
|
||||
|
||||
|
||||
class ShareGPTDataset(BenchmarkDataset):
|
||||
"""Implements the ShareGPT dataset. The first human message of each conversation is used to build a prompt."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset_path: str,
|
||||
seed: int,
|
||||
hf_dataset_id: str = "Crystalcareai/Code-feedback-sharegpt-renamed",
|
||||
hf_split: str = "train",
|
||||
truncate_prompt: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the ShareGPTDataset.
|
||||
|
||||
Args:
|
||||
dataset_path: The path to the dataset on disk.
|
||||
seed: The seed for the random number generator.
|
||||
hf_dataset_id: The Hugging Face dataset ID to download if the dataset is not found on disk.
|
||||
hf_split: The Hugging Face split to load from the dataset.
|
||||
truncate_prompt: Maximum prompt length so that the prompt fits in the model's context window.
|
||||
"""
|
||||
super().__init__(dataset_path, seed)
|
||||
self._seed = seed
|
||||
|
||||
self._hf_dataset_id = hf_dataset_id
|
||||
self._hf_split = hf_split
|
||||
self._truncate_prompt = truncate_prompt
|
||||
|
||||
self._data: list[Dict] | None = None
|
||||
|
||||
def load_data(self) -> None:
|
||||
"""Load data from the dataset path into memory."""
|
||||
if self._data is None:
|
||||
self._data = self._load_dataset_data()
|
||||
|
||||
def sample(self, num_requests: int) -> List[Dict]:
|
||||
"""Sample prompts from the loaded dataset."""
|
||||
if self._data is None:
|
||||
self.load_data()
|
||||
|
||||
# Extract all valid prompts from the dataset
|
||||
all_prompts = []
|
||||
for item in self._data:
|
||||
prompt_data = self._extract_prompt(item)
|
||||
if prompt_data is not None:
|
||||
all_prompts.append(prompt_data)
|
||||
|
||||
if not all_prompts:
|
||||
raise ValueError("ShareGPT dataset yielded no usable prompts")
|
||||
|
||||
# Replicate samples if num_requests exceeds available samples
|
||||
if num_requests <= len(all_prompts):
|
||||
return all_prompts[:num_requests]
|
||||
|
||||
full_copies = num_requests // len(all_prompts)
|
||||
remainder = num_requests % len(all_prompts)
|
||||
prompts = all_prompts * full_copies + all_prompts[:remainder]
|
||||
return prompts
|
||||
|
||||
def _load_dataset(self):
|
||||
"""Load dataset from disk or Hugging Face."""
|
||||
path = Path(self._dataset_path)
|
||||
print(f"Attempting to load dataset from {path}")
|
||||
print(f"Dataset exists on disk: {path.exists()}")
|
||||
|
||||
try:
|
||||
if path.exists():
|
||||
dataset = load_from_disk(str(path))
|
||||
else:
|
||||
print(
|
||||
f"Dataset not found on disk, downloading from Hugging Face: {self._hf_dataset_id}"
|
||||
)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
dataset = load_dataset(self._hf_dataset_id, split=self._hf_split)
|
||||
dataset.save_to_disk(str(path))
|
||||
return dataset
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error loading ShareGPT dataset: {e}")
|
||||
|
||||
def _load_dataset_data(self) -> List[Dict]:
|
||||
"""Load and process dataset data into a list of dictionaries."""
|
||||
ds = self._load_dataset().shuffle(seed=self._seed)
|
||||
data = []
|
||||
|
||||
for i, row in enumerate(ds):
|
||||
data.append(row)
|
||||
|
||||
print(f"Loaded {len(data)} samples from dataset")
|
||||
return data
|
||||
|
||||
def _extract_prompt(self, item: Dict) -> Dict | None:
|
||||
"""
|
||||
Extracts the first human message of a conversation or None.
|
||||
|
||||
The ShareGPT schema uses {"role": "human", "value": ...} for user
|
||||
turns.
|
||||
"""
|
||||
messages = item.get("messages") or item.get("conversations") or []
|
||||
prompt = next(
|
||||
(
|
||||
str(msg.get("value", "")).strip()
|
||||
for msg in messages
|
||||
if msg.get("role") in {"human", "user"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Only return a valid prompt if it's not empty
|
||||
if prompt and prompt.strip():
|
||||
if self._truncate_prompt:
|
||||
prompt = prompt[: self._truncate_prompt]
|
||||
return {"prompt": prompt}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class TaskType:
|
||||
@classmethod
|
||||
def values(cls):
|
||||
"""Return a set of all valid task type values."""
|
||||
return {
|
||||
value
|
||||
for key, value in vars(cls).items()
|
||||
if not key.startswith("_") and isinstance(value, str)
|
||||
}
|
||||
|
||||
|
||||
class vLLMTaskType(TaskType):
|
||||
"""The type of task to run on the vLLM engine."""
|
||||
|
||||
# Generate text.
|
||||
GENERATE = "generate"
|
||||
|
||||
# Generate embeddings.
|
||||
EMBED = "embed"
|
||||
|
||||
# Classification (e.g., sequence classification models).
|
||||
CLASSIFY = "classify"
|
||||
|
||||
# Scoring (e.g., cross-encoder models).
|
||||
SCORE = "score"
|
||||
|
||||
|
||||
class SGLangTaskType(TaskType):
|
||||
"""The type of task to run on the SGLang engine."""
|
||||
|
||||
# Generate text.
|
||||
GENERATE = "generate"
|
||||
|
||||
|
||||
TypeVLLMTaskType = Literal[tuple(vLLMTaskType.values())]
|
||||
TypeSGLangTaskType = Literal[tuple(SGLangTaskType.values())]
|
||||
@@ -0,0 +1,23 @@
|
||||
from ray.llm._internal.batch.observability.logging.setup import (
|
||||
setup_logging,
|
||||
)
|
||||
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
|
||||
|
||||
_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,40 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ray._common.filters import CoreContextFilter
|
||||
|
||||
|
||||
def _setup_logger(logger_name: str) -> None:
|
||||
"""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 Data logger is already setup and has handlers.
|
||||
|
||||
Args:
|
||||
logger_name: logger name used to get the logger.
|
||||
"""
|
||||
logger = logging.getLogger(logger_name)
|
||||
llm_logger = logging.getLogger("ray.data")
|
||||
|
||||
# Skip setup if the logger already has handlers setup or if the parent (Data
|
||||
# logger) has handlers.
|
||||
if logger.handlers or llm_logger.handlers:
|
||||
return
|
||||
|
||||
# Set up stream handler, which logs to console as plaintext.
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.addFilter(CoreContextFilter())
|
||||
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 Data logger.
|
||||
|
||||
Loggers by default are logging to stdout, and are expected to be scraped by an
|
||||
external process.
|
||||
"""
|
||||
logger_name = f"ray.data.{name}"
|
||||
_setup_logger(logger_name)
|
||||
return logging.getLogger(logger_name)
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
|
||||
from ray._common.filters import CoreContextFilter
|
||||
from ray._common.formatters import JSONFormatter
|
||||
|
||||
|
||||
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.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,162 @@
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Tuple, Union
|
||||
|
||||
import ray
|
||||
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
|
||||
from ray._common.usage.usage_lib import record_extra_usage_tag
|
||||
from ray.llm._internal.batch.observability.logging import get_logger
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
|
||||
LLM_BATCH_TELEMETRY_NAMESPACE = "llm_batch_telemetry"
|
||||
LLM_BATCH_TELEMETRY_ACTOR_NAME = "llm_batch_telemetry"
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BatchModelTelemetry(BaseModelExtended):
|
||||
# Dedup identity only; never recorded as a tag value. A hash of model_source
|
||||
# so distinct models that share an architecture stay separate in the dedup
|
||||
# key while the cleartext model name never reaches the head-node actor.
|
||||
model_id_hash: str = ""
|
||||
processor_config_name: str = ""
|
||||
model_architecture: str = ""
|
||||
batch_size: int = 0
|
||||
accelerator_type: str = ""
|
||||
concurrency: Union[int, Tuple[int, int]] = 0
|
||||
task_type: str = ""
|
||||
|
||||
# For the parallel size, 0 means not supported.
|
||||
pipeline_parallel_size: int = 0
|
||||
tensor_parallel_size: int = 0
|
||||
data_parallel_size: int = 0
|
||||
|
||||
|
||||
class BatchTelemetryTags(str, Enum):
|
||||
"""Telemetry tags for RayLLM Batch."""
|
||||
|
||||
LLM_BATCH_PROCESSOR_CONFIG_NAME = "LLM_BATCH_PROCESSOR_CONFIG_NAME"
|
||||
LLM_BATCH_MODEL_ARCHITECTURE = "LLM_BATCH_MODEL_ARCHITECTURE"
|
||||
LLM_BATCH_SIZE = "LLM_BATCH_SIZE"
|
||||
LLM_BATCH_ACCELERATOR_TYPE = "LLM_BATCH_ACCELERATOR_TYPE"
|
||||
LLM_BATCH_CONCURRENCY = "LLM_BATCH_CONCURRENCY"
|
||||
LLM_BATCH_TASK_TYPE = "LLM_BATCH_TASK_TYPE"
|
||||
LLM_BATCH_PIPELINE_PARALLEL_SIZE = "LLM_BATCH_PIPELINE_PARALLEL_SIZE"
|
||||
LLM_BATCH_TENSOR_PARALLEL_SIZE = "LLM_BATCH_TENSOR_PARALLEL_SIZE"
|
||||
LLM_BATCH_DATA_PARALLEL_SIZE = "LLM_BATCH_DATA_PARALLEL_SIZE"
|
||||
|
||||
|
||||
@ray.remote(
|
||||
name=LLM_BATCH_TELEMETRY_ACTOR_NAME,
|
||||
namespace=LLM_BATCH_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 full telemetry identity (incl. model_id_hash) so repeated
|
||||
# identical processor builds overwrite while distinct models/configs
|
||||
# remain separate.
|
||||
self._tracking_telemetries: Dict[str, BatchModelTelemetry] = {}
|
||||
self._record_tag_func = record_extra_usage_tag
|
||||
|
||||
def _update_record_tag_func(self, record_tag_func: Callable) -> None:
|
||||
self._record_tag_func = record_tag_func
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""Only used in tests to clear accumulated telemetries."""
|
||||
self._tracking_telemetries = {}
|
||||
|
||||
def generate_report(self) -> Dict[str, str]:
|
||||
return {
|
||||
BatchTelemetryTags.LLM_BATCH_PROCESSOR_CONFIG_NAME: ",".join(
|
||||
[t.processor_config_name for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_MODEL_ARCHITECTURE: ",".join(
|
||||
[t.model_architecture for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_SIZE: ",".join(
|
||||
[str(t.batch_size) for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_ACCELERATOR_TYPE: ",".join(
|
||||
[t.accelerator_type for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_CONCURRENCY: ",".join(
|
||||
[str(t.concurrency) for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_TASK_TYPE: ",".join(
|
||||
[t.task_type for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_PIPELINE_PARALLEL_SIZE: ",".join(
|
||||
[
|
||||
str(t.pipeline_parallel_size)
|
||||
for t in self._tracking_telemetries.values()
|
||||
]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_TENSOR_PARALLEL_SIZE: ",".join(
|
||||
[
|
||||
str(t.tensor_parallel_size)
|
||||
for t in self._tracking_telemetries.values()
|
||||
]
|
||||
),
|
||||
BatchTelemetryTags.LLM_BATCH_DATA_PARALLEL_SIZE: ",".join(
|
||||
[str(t.data_parallel_size) for t in self._tracking_telemetries.values()]
|
||||
),
|
||||
}
|
||||
|
||||
def record(self, telemetry: BatchModelTelemetry) -> None:
|
||||
"""Upsert by identity and record telemetries."""
|
||||
from ray._common.usage.usage_lib import TagKey
|
||||
|
||||
self._tracking_telemetries[telemetry.model_dump_json()] = telemetry
|
||||
for key, value in self.generate_report().items():
|
||||
try:
|
||||
self._record_tag_func(TagKey.Value(key), value)
|
||||
except ValueError:
|
||||
# Tag not in the installed usage proto; skip rather than fail.
|
||||
continue
|
||||
|
||||
|
||||
class TelemetryAgent:
|
||||
"""Wrapper around the telemetry agent that calls the remote method until
|
||||
push_telemetry_report is called."""
|
||||
|
||||
def __init__(self):
|
||||
# Creation must never break processor construction, so swallow failures
|
||||
# (e.g. Ray not initialized, transient GCS issues) and degrade to a no-op.
|
||||
try:
|
||||
# get_if_exists makes creation atomic across concurrent drivers.
|
||||
self.remote_telemetry_agent = _TelemetryAgent.options(
|
||||
name=LLM_BATCH_TELEMETRY_ACTOR_NAME,
|
||||
namespace=LLM_BATCH_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()
|
||||
except Exception:
|
||||
self.remote_telemetry_agent = None
|
||||
logger.exception("Failed to initialize LLM batch telemetry agent")
|
||||
|
||||
def _update_record_tag_func(self, record_tag_func: Callable):
|
||||
if self.remote_telemetry_agent is not None:
|
||||
self.remote_telemetry_agent._update_record_tag_func.remote(record_tag_func)
|
||||
|
||||
def push_telemetry_report(self, telemetry: BatchModelTelemetry):
|
||||
# Telemetry must never break processor construction.
|
||||
if self.remote_telemetry_agent is None:
|
||||
return
|
||||
try:
|
||||
ray.get(self.remote_telemetry_agent.record.remote(telemetry))
|
||||
except Exception:
|
||||
logger.exception("Failed to push LLM batch telemetry")
|
||||
|
||||
|
||||
def get_or_create_telemetry_agent() -> TelemetryAgent:
|
||||
"""Helper to get or create the telemetry agent."""
|
||||
return TelemetryAgent()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Lazy re-exports for batch processors.
|
||||
|
||||
Each ``*_proc.py`` module pulls in heavy ML dependencies (transformers, vllm,
|
||||
sglang, ...). Eagerly importing all of them here causes a single
|
||||
``from ray.llm._internal.batch.processor import HttpRequestProcessorConfig``
|
||||
to load the entire ML stack and to fail when optional dependencies (e.g.
|
||||
sglang) are not installed.
|
||||
|
||||
We use PEP 562 ``__getattr__`` to load each engine-specific config only when
|
||||
it is first referenced. The ``base`` module is imported eagerly because it is
|
||||
cheap and exports types used everywhere else.
|
||||
|
||||
Note on registration side effects: each ``*_proc.py`` calls
|
||||
``ProcessorBuilder.register(...)`` at import time. With lazy loading the
|
||||
registration happens the first time the corresponding config is accessed via
|
||||
this package -- which is exactly when a user constructs the config and asks
|
||||
``ProcessorBuilder.build`` to build a processor for it, so the registry is
|
||||
populated in time for every realistic usage.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ray.llm._internal.batch.processor.base import (
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
ProcessorConfig,
|
||||
)
|
||||
|
||||
# Mapping of public attribute name -> (submodule, attribute name in submodule).
|
||||
# Each entry is an engine-specific processor config whose defining submodule
|
||||
# transitively imports heavy optional dependencies.
|
||||
_LAZY_ATTRS = {
|
||||
"HttpRequestProcessorConfig": (
|
||||
"http_request_proc",
|
||||
"HttpRequestProcessorConfig",
|
||||
),
|
||||
"ServeDeploymentProcessorConfig": (
|
||||
"serve_deployment_proc",
|
||||
"ServeDeploymentProcessorConfig",
|
||||
),
|
||||
"SGLangEngineProcessorConfig": (
|
||||
"sglang_engine_proc",
|
||||
"SGLangEngineProcessorConfig",
|
||||
),
|
||||
"vLLMEngineProcessorConfig": (
|
||||
"vllm_engine_proc",
|
||||
"vLLMEngineProcessorConfig",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazily import engine-specific processor configs (PEP 562)."""
|
||||
try:
|
||||
submodule, attr = _LAZY_ATTRS[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(f"{__name__}.{submodule}")
|
||||
value = getattr(module, attr)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()).union(_LAZY_ATTRS))
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.llm._internal.batch.processor.http_request_proc import ( # noqa: F401
|
||||
HttpRequestProcessorConfig,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.serve_deployment_proc import ( # noqa: F401
|
||||
ServeDeploymentProcessorConfig,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.sglang_engine_proc import ( # noqa: F401
|
||||
SGLangEngineProcessorConfig,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.vllm_engine_proc import ( # noqa: F401
|
||||
vLLMEngineProcessorConfig,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProcessorConfig",
|
||||
"ProcessorBuilder",
|
||||
"HttpRequestProcessorConfig",
|
||||
"vLLMEngineProcessorConfig",
|
||||
"SGLangEngineProcessorConfig",
|
||||
"ServeDeploymentProcessorConfig",
|
||||
"Processor",
|
||||
]
|
||||
@@ -0,0 +1,533 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from ray.data import Dataset
|
||||
from ray.data.block import UserDefinedFunction
|
||||
from ray.llm._internal.batch.stages import (
|
||||
StatefulStage,
|
||||
wrap_postprocess,
|
||||
wrap_preprocess,
|
||||
)
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
from ray.util.annotations import DeveloperAPI, PublicAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessorConfig(BaseModelExtended):
|
||||
"""The processor configuration."""
|
||||
|
||||
batch_size: int = Field(
|
||||
default=32,
|
||||
description="Large batch sizes are likely to saturate the compute resources "
|
||||
"and could achieve higher throughput. On the other hand, small batch sizes "
|
||||
"are more fault-tolerant and could reduce bubbles in the data pipeline. "
|
||||
"You can tune the batch size to balance the throughput and fault-tolerance "
|
||||
"based on your use case. Defaults to 32.",
|
||||
)
|
||||
resources_per_bundle: Optional[Dict[str, float]] = Field(
|
||||
default=None,
|
||||
description="[DEPRECATED] This parameter is deprecated and will be removed in a future version. ",
|
||||
deprecated=True,
|
||||
)
|
||||
accelerator_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The accelerator type used by the LLM stage in a processor. "
|
||||
"Default to None, meaning that only the CPU will be used.",
|
||||
)
|
||||
concurrency: Union[int, Tuple[int, int]] = Field(
|
||||
default=1,
|
||||
description="The number of workers for data parallelism. Default to 1. "
|
||||
"If ``concurrency`` is a ``tuple`` ``(m, n)``, Ray creates an autoscaling "
|
||||
"actor pool that scales between ``m`` and ``n`` workers (``1 <= m <= n``). "
|
||||
"If ``concurrency`` is an ``int`` ``n``, Ray uses either a fixed pool of ``n`` "
|
||||
"workers or an autoscaling pool from ``1`` to ``n`` workers, depending on "
|
||||
"the processor and stage.",
|
||||
)
|
||||
|
||||
experimental: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="[Experimental] Experimental configurations. "
|
||||
"Supported keys:\n"
|
||||
"`max_tasks_in_flight_per_actor`: [DEPRECATED] Prefer the top-level "
|
||||
"`max_tasks_in_flight_per_actor` field on `OfflineProcessorConfig`. "
|
||||
"Setting it here is still respected (and overridden by the top-level "
|
||||
"field if both are set), but logs a deprecation warning.",
|
||||
)
|
||||
|
||||
@field_validator("concurrency")
|
||||
def validate_concurrency(
|
||||
cls, concurrency: Union[int, Tuple[int, int]]
|
||||
) -> Union[int, Tuple[int, int]]:
|
||||
"""Validate that `concurrency` is either:
|
||||
- a positive int, or
|
||||
- a 2-tuple `(min, max)` of positive ints with `min <= max`.
|
||||
"""
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
if isinstance(concurrency, int):
|
||||
require(
|
||||
concurrency > 0,
|
||||
f"A positive integer for `concurrency` is expected! Got: `{concurrency}`.",
|
||||
)
|
||||
elif isinstance(concurrency, tuple):
|
||||
require(
|
||||
all(c > 0 for c in concurrency),
|
||||
f"`concurrency` tuple items must be positive integers! Got: `{concurrency}`.",
|
||||
)
|
||||
|
||||
min_concurrency, max_concurrency = concurrency
|
||||
require(
|
||||
min_concurrency <= max_concurrency,
|
||||
f"min > max in the concurrency tuple `{concurrency}`!",
|
||||
)
|
||||
return concurrency
|
||||
|
||||
def get_concurrency(self, autoscaling_enabled: bool = True) -> Dict[str, int]:
|
||||
"""Return a normalized dict of worker pool parameters from `self.concurrency`.
|
||||
|
||||
Behavior:
|
||||
- If `concurrency` is an int `n`:
|
||||
- `autoscaling_enabled` is True -> return `{"min_size": 1, "max_size": n}` (autoscaling).
|
||||
- `autoscaling_enabled` is False -> return `{"size": n}` (fixed-size pool).
|
||||
- If `concurrency` is a 2-tuple `(m, n)`, return `{"min_size": m, "max_size": n}`
|
||||
(the `autoscaling_enabled` flag is ignored).
|
||||
|
||||
Args:
|
||||
autoscaling_enabled: When False, treat an integer `concurrency` as fixed size;
|
||||
otherwise treat it as an autoscaling range from 1 to n. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: A dictionary with either:
|
||||
- `{"size": n}` for fixed-size pools
|
||||
- `{"min_size": m, "max_size": n}` for autoscaling pools
|
||||
|
||||
Examples:
|
||||
>>> self.concurrency = (2, 4)
|
||||
>>> self.get_concurrency()
|
||||
{'min_size': 2, 'max_size': 4}
|
||||
>>> self.concurrency = 4
|
||||
>>> self.get_concurrency()
|
||||
{'min_size': 1, 'max_size': 4}
|
||||
>>> self.get_concurrency(autoscaling_enabled=False)
|
||||
{'size': 4}
|
||||
"""
|
||||
if isinstance(self.concurrency, int):
|
||||
if autoscaling_enabled:
|
||||
return {"min_size": 1, "max_size": self.concurrency}
|
||||
else:
|
||||
return {"size": self.concurrency}
|
||||
return {
|
||||
"min_size": self.concurrency[0],
|
||||
"max_size": self.concurrency[1],
|
||||
}
|
||||
|
||||
class Config:
|
||||
validate_assignment = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class OfflineProcessorConfig(ProcessorConfig):
|
||||
"""The processor configuration for offline processing."""
|
||||
|
||||
model_source: str = Field(
|
||||
description="The model source to use for the offline processing.",
|
||||
)
|
||||
runtime_env: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="The runtime environment to use for the offline processing.",
|
||||
)
|
||||
max_pending_requests: Optional[int] = Field(
|
||||
default=None,
|
||||
description="The maximum number of pending requests. If not specified, "
|
||||
"will use the default value from the backend engine.",
|
||||
)
|
||||
max_concurrent_batches: int = Field(
|
||||
default=8,
|
||||
description="The maximum number of concurrent batches in the engine. "
|
||||
"This is to overlap the batch processing to avoid the tail latency of "
|
||||
"each batch. The default value may not be optimal when the batch size "
|
||||
"or the batch processing latency is too small, but it should be good "
|
||||
"enough for batch size >= 32. Sets the engine actor's Ray Core "
|
||||
"`max_concurrency`.",
|
||||
)
|
||||
max_tasks_in_flight_per_actor: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Max tasks Ray Data submits concurrently to each engine "
|
||||
"actor. Passed through to `ray.data.ActorPoolStrategy`. If unset, Ray "
|
||||
"Data uses `ray.data.DataContext.max_tasks_in_flight_per_actor` if set "
|
||||
"globally. Otherwise, it defaults to `2 * max_concurrent_batches`; the "
|
||||
"factor can be overridden via the "
|
||||
"`RAY_DATA_ACTOR_DEFAULT_MAX_TASKS_IN_FLIGHT_TO_MAX_CONCURRENCY_FACTOR` "
|
||||
"env var. "
|
||||
"Setting this lower than `max_concurrent_batches` can underutilize the "
|
||||
"engine actor because Ray Data submits fewer tasks than the actor can "
|
||||
"process concurrently.",
|
||||
)
|
||||
should_continue_on_error: bool = Field(
|
||||
default=False,
|
||||
description="If True, continue processing when inference fails for a row "
|
||||
"instead of raising an exception. Failed rows will have a non-empty "
|
||||
"'__inference_error__' column containing the error message, and other "
|
||||
"output columns will be empty strings. Error rows bypass postprocess. "
|
||||
"If False (default), any inference error will raise an exception.",
|
||||
)
|
||||
|
||||
# Processor stage configurations (legacy booleans, will be deprecated).
|
||||
# TODO (jeffreywang): Remove apply_chat_template, chat_template, tokenize,
|
||||
# detokenize in Ray 2.57.0 in favor of the *_stage fields below.
|
||||
apply_chat_template: bool = Field(
|
||||
default=True,
|
||||
description="[DEPRECATED] Prefer `chat_template_stage`. Whether to apply chat template.",
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
default=None,
|
||||
description="[DEPRECATED] Prefer `chat_template_stage.chat_template`. The chat template to use.",
|
||||
)
|
||||
tokenize: bool = Field(
|
||||
default=True,
|
||||
description="[DEPRECATED] Prefer `tokenize_stage`. Whether to tokenize input before engine.",
|
||||
)
|
||||
detokenize: bool = Field(
|
||||
default=True,
|
||||
description="[DEPRECATED] Prefer `detokenize_stage`. Whether to detokenize the output.",
|
||||
)
|
||||
# New nested stage configuration (bool | dict | typed config).
|
||||
chat_template_stage: Any = Field(
|
||||
default=True,
|
||||
description="Chat templating stage config (bool | dict | ChatTemplateStageConfig).",
|
||||
)
|
||||
tokenize_stage: Any = Field(
|
||||
default=True,
|
||||
description="Tokenizer stage config (bool | dict | TokenizerStageConfig).",
|
||||
)
|
||||
detokenize_stage: Any = Field(
|
||||
default=True,
|
||||
description="Detokenizer stage config (bool | dict | DetokenizeStageConfig).",
|
||||
)
|
||||
prepare_multimodal_stage: Any = Field(
|
||||
default=False,
|
||||
description="Prepare multimodal stage config (bool | dict | PrepareMultimodalStageConfig).",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
def _coerce_legacy_to_stage_config(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Only set stage fields if not explicitly provided.
|
||||
# Emit deprecation warnings when legacy boolean flags are used.
|
||||
|
||||
# Chat template stage: special case (handles both apply_chat_template and chat_template fields)
|
||||
if "chat_template_stage" not in values:
|
||||
if "apply_chat_template" in values or "chat_template" in values:
|
||||
logger.warning(
|
||||
"The `apply_chat_template` and `chat_template` fields are deprecated. "
|
||||
"Use `chat_template_stage` instead. For example: "
|
||||
"`chat_template_stage=ChatTemplateStageConfig(enabled=True, chat_template='...')` "
|
||||
"or `chat_template_stage={'enabled': True, 'chat_template': '...'}`. "
|
||||
"This will raise an error in a future version."
|
||||
)
|
||||
enabled_value = values.get("apply_chat_template")
|
||||
enabled = enabled_value if enabled_value is not None else True
|
||||
stage: Dict[str, Any] = {"enabled": enabled}
|
||||
if values.get("chat_template") is not None:
|
||||
stage["chat_template"] = values["chat_template"]
|
||||
values["chat_template_stage"] = stage
|
||||
|
||||
# Other stages: simple boolean-to-stage mapping
|
||||
stage_mappings = [
|
||||
("tokenize_stage", "tokenize", True, "TokenizerStageConfig"),
|
||||
("detokenize_stage", "detokenize", True, "DetokenizeStageConfig"),
|
||||
]
|
||||
for (
|
||||
stage_field,
|
||||
legacy_field,
|
||||
default_enabled,
|
||||
config_class_name,
|
||||
) in stage_mappings:
|
||||
if stage_field not in values and legacy_field in values:
|
||||
logger.warning(
|
||||
f"The `{legacy_field}` field is deprecated. "
|
||||
f"Use `{stage_field}` instead. For example: "
|
||||
f"`{stage_field}={config_class_name}(enabled=True)` "
|
||||
f"or `{stage_field}={{'enabled': True}}`. "
|
||||
"This will raise an error in a future version."
|
||||
)
|
||||
legacy_value = values.get(legacy_field)
|
||||
enabled = default_enabled if legacy_value is None else legacy_value
|
||||
values[stage_field] = {"enabled": enabled}
|
||||
|
||||
return values
|
||||
|
||||
@model_validator(mode="before")
|
||||
def _migrate_experimental_max_tasks_in_flight_per_actor(
|
||||
cls, values: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Migrate deprecated `experimental[max_tasks_in_flight_per_actor]` to
|
||||
the top-level field; top-level wins if both are set."""
|
||||
experimental = values.get("experimental") or {}
|
||||
if "max_tasks_in_flight_per_actor" in experimental:
|
||||
logger.warning(
|
||||
"Setting `max_tasks_in_flight_per_actor` via `experimental` is "
|
||||
"deprecated; use the top-level `max_tasks_in_flight_per_actor` "
|
||||
"field on `OfflineProcessorConfig` instead. The value in "
|
||||
"`experimental` is still respected for now (and overridden by "
|
||||
"the top-level field if both are set), but will be removed in "
|
||||
"a future version."
|
||||
)
|
||||
if values.get("max_tasks_in_flight_per_actor") is None:
|
||||
values["max_tasks_in_flight_per_actor"] = experimental[
|
||||
"max_tasks_in_flight_per_actor"
|
||||
]
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _warn_if_max_tasks_in_flight_underutilizes_actor(self):
|
||||
if (
|
||||
self.max_tasks_in_flight_per_actor is not None
|
||||
and self.max_tasks_in_flight_per_actor < self.max_concurrent_batches
|
||||
):
|
||||
logger.warning(
|
||||
"Setting `max_tasks_in_flight_per_actor` (%s) lower than "
|
||||
"`max_concurrent_batches` (%s) can underutilize each engine "
|
||||
"actor because Ray Data will submit fewer tasks than the actor "
|
||||
"can process concurrently.",
|
||||
self.max_tasks_in_flight_per_actor,
|
||||
self.max_concurrent_batches,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class Processor:
|
||||
"""A processor is composed of a preprocess stage, followed by one or more
|
||||
processing stages, and finally a postprocess stage. We use processor as a
|
||||
paradigm for processing data using LLMs.
|
||||
|
||||
Args:
|
||||
config: The processor config.
|
||||
stages: List of processing stages.
|
||||
preprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a preprocessed row (dict). The output row must contain the
|
||||
required fields for the following processing stages.
|
||||
postprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a postprocessed row (dict).
|
||||
preprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
preprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
postprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
postprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
"""
|
||||
|
||||
# The internal used data column name ("__data"). Your input
|
||||
# dataset should not contain this column. If you want to use this column
|
||||
# in your input dataset, you have to derive and customize Processor.
|
||||
DATA_COLUMN: str = "__data"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ProcessorConfig,
|
||||
stages: List[StatefulStage],
|
||||
preprocess: Optional[UserDefinedFunction] = None,
|
||||
postprocess: Optional[UserDefinedFunction] = None,
|
||||
preprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
postprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.config = config
|
||||
self.preprocess = None
|
||||
self.postprocess = None
|
||||
self.preprocess_map_kwargs = preprocess_map_kwargs or {}
|
||||
self.postprocess_map_kwargs = postprocess_map_kwargs or {}
|
||||
self.stages: OrderedDict[str, StatefulStage] = OrderedDict()
|
||||
|
||||
# NOTE (Kourosh): If pre/postprocess is not provided, use the identity function.
|
||||
# Wrapping is required even if they are identity functions, b/c data_column
|
||||
# gets inserted/removed via wrap_preprocess/wrap_postprocess.
|
||||
preprocess = preprocess or (lambda row: row)
|
||||
postprocess = postprocess or (lambda row: row)
|
||||
|
||||
self.preprocess = wrap_preprocess(
|
||||
preprocess,
|
||||
self.DATA_COLUMN,
|
||||
)
|
||||
|
||||
# When should_continue_on_error is enabled, include __inference_error__ column
|
||||
# in all output rows for consistent schema (Empty string for success, message for error).
|
||||
include_error_column = getattr(config, "should_continue_on_error", False)
|
||||
self.postprocess = wrap_postprocess(
|
||||
postprocess,
|
||||
self.DATA_COLUMN,
|
||||
include_error_column=include_error_column,
|
||||
)
|
||||
|
||||
for stage in stages:
|
||||
self._append_stage(stage)
|
||||
|
||||
def __call__(self, dataset: Dataset) -> Dataset:
|
||||
"""Execute the processor:
|
||||
preprocess -> stages -> postprocess.
|
||||
Note that the dataset won't be materialized during the execution.
|
||||
|
||||
Args:
|
||||
dataset: The input dataset.
|
||||
|
||||
Returns:
|
||||
The output dataset.
|
||||
"""
|
||||
if self.preprocess is not None:
|
||||
dataset = dataset.map(self.preprocess, **self.preprocess_map_kwargs)
|
||||
|
||||
# Apply stages.
|
||||
for stage in self.stages.values():
|
||||
kwargs = stage.get_dataset_map_batches_kwargs(
|
||||
batch_size=self.config.batch_size,
|
||||
data_column=self.DATA_COLUMN,
|
||||
)
|
||||
dataset = dataset.map_batches(stage.fn, **kwargs)
|
||||
|
||||
if self.postprocess is not None:
|
||||
dataset = dataset.map(self.postprocess, **self.postprocess_map_kwargs)
|
||||
return dataset
|
||||
|
||||
def _append_stage(self, stage: StatefulStage) -> None:
|
||||
"""Append a stage before postprocess. The stage class name will be used as
|
||||
the stage name. If there are multiple stages with the same type, a suffix
|
||||
will be added to the stage name to avoid conflicts.
|
||||
|
||||
Args:
|
||||
stage: The stage to append.
|
||||
"""
|
||||
stage_name = type(stage).__name__
|
||||
|
||||
# When a processor has multiple stages with the same type,
|
||||
# append a index suffix to the stage name to avoid conflicts.
|
||||
if stage_name in self.stages:
|
||||
num_same_type_stage = len([s for s in self.stages.values() if s is stage])
|
||||
stage_name = f"{stage_name}_{num_same_type_stage + 1}"
|
||||
self.stages[stage_name] = stage
|
||||
|
||||
def list_stage_names(self) -> List[str]:
|
||||
"""List the stage names of this processor in order. Preprocess and postprocess
|
||||
are not included.
|
||||
|
||||
Returns:
|
||||
A list of stage names.
|
||||
"""
|
||||
return list(self.stages.keys())
|
||||
|
||||
def get_stage_by_name(self, name: str) -> StatefulStage:
|
||||
"""Get a particular stage by its name. If the stage is not found,
|
||||
a ValueError will be raised.
|
||||
|
||||
Args:
|
||||
name: The stage name.
|
||||
|
||||
Returns:
|
||||
The pipeline stage.
|
||||
"""
|
||||
if name in self.stages:
|
||||
return self.stages[name]
|
||||
raise ValueError(f"Stage {name} not found")
|
||||
|
||||
def log_input_column_names(self):
|
||||
"""Log.info the input stage and column names of this processor.
|
||||
If the input dataset does not contain these columns, you have to
|
||||
provide a preprocess function to bridge the gap.
|
||||
"""
|
||||
name, stage = list(self.stages.items())[0]
|
||||
expected_input_keys = stage.get_required_input_keys()
|
||||
optional_input_keys = stage.get_optional_input_keys()
|
||||
|
||||
message = f"The first stage of the processor is {name}."
|
||||
if expected_input_keys:
|
||||
message += "\nRequired input columns:\n"
|
||||
message += "\n".join(f"\t{k}: {v}" for k, v in expected_input_keys.items())
|
||||
if optional_input_keys:
|
||||
message += "\nOptional input columns:\n"
|
||||
message += "\n".join(f"\t{k}: {v}" for k, v in optional_input_keys.items())
|
||||
|
||||
logger.info(message)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ProcessorBuilder:
|
||||
"""Build a processor based on the configuration."""
|
||||
|
||||
_registry: Dict[str, Callable] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, config_type: Type[ProcessorConfig], builder: Callable) -> None:
|
||||
"""A decorator to associate a particular pipeline config
|
||||
with its build function.
|
||||
"""
|
||||
type_name = config_type.__name__
|
||||
if type_name in cls._registry:
|
||||
raise ValueError(f"Processor config type {type_name} already registered.")
|
||||
cls._registry[type_name] = builder
|
||||
|
||||
@classmethod
|
||||
def clear_registry(cls) -> None:
|
||||
"""Clear the processor builder registry."""
|
||||
cls._registry.clear()
|
||||
|
||||
@classmethod
|
||||
def validate_builder_kwargs(cls, builder_kwargs: Optional[Dict[str, Any]]) -> None:
|
||||
"""Validate builder kwargs for conflicts with reserved keys.
|
||||
|
||||
Args:
|
||||
builder_kwargs: Optional additional kwargs to pass to the processor builder
|
||||
function.
|
||||
|
||||
Raises:
|
||||
ValueError: If builder_kwargs contains reserved keys that conflict with
|
||||
explicit arguments.
|
||||
"""
|
||||
if builder_kwargs is not None:
|
||||
# Check for conflicts with explicitly passed arguments
|
||||
reserved_keys = {
|
||||
"preprocess",
|
||||
"postprocess",
|
||||
"preprocess_map_kwargs",
|
||||
"postprocess_map_kwargs",
|
||||
}
|
||||
conflicting_keys = reserved_keys & builder_kwargs.keys()
|
||||
if conflicting_keys:
|
||||
raise ValueError(
|
||||
f"builder_kwargs cannot contain {conflicting_keys} as these are "
|
||||
"passed as explicit arguments to build_processor. "
|
||||
"Please pass these directly instead of in builder_kwargs."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
config: ProcessorConfig,
|
||||
override_stage_config_fn: Optional[Callable] = None,
|
||||
**kwargs,
|
||||
) -> Processor:
|
||||
"""Build a processor.
|
||||
|
||||
Args:
|
||||
config: The processor config.
|
||||
override_stage_config_fn: Custom stages configurations.
|
||||
**kwargs: Additional keyword arguments to pass through to the
|
||||
registered builder function. The builder function must accept
|
||||
these kwargs in its signature, otherwise a TypeError will be raised.
|
||||
|
||||
Returns:
|
||||
The built processor.
|
||||
"""
|
||||
type_name = type(config).__name__
|
||||
if type_name not in cls._registry:
|
||||
raise ValueError(
|
||||
f"Processor config type {type_name} not registered. "
|
||||
f"Available types: {cls._registry.keys()}"
|
||||
)
|
||||
processor = cls._registry[type_name](config, **kwargs)
|
||||
if override_stage_config_fn is not None:
|
||||
for name, stage in processor.stages.items():
|
||||
override_stage_config_fn(name, stage)
|
||||
return processor
|
||||
@@ -0,0 +1,146 @@
|
||||
"""The HTTP request processor."""
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ray.data.block import UserDefinedFunction
|
||||
from ray.llm._internal.batch.observability.usage_telemetry.usage import (
|
||||
BatchModelTelemetry,
|
||||
get_or_create_telemetry_agent,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.base import (
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
ProcessorConfig,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.utils import build_cpu_stage_map_kwargs
|
||||
from ray.llm._internal.batch.stages import HttpRequestStage
|
||||
from ray.llm._internal.batch.stages.configs import (
|
||||
HttpRequestStageConfig,
|
||||
resolve_stage_config,
|
||||
)
|
||||
|
||||
|
||||
class HttpRequestProcessorConfig(ProcessorConfig):
|
||||
"""The configuration for the HTTP request processor."""
|
||||
|
||||
batch_size: int = Field(
|
||||
default=64,
|
||||
description="The batch size.",
|
||||
)
|
||||
url: str = Field(
|
||||
description="The URL to query.",
|
||||
)
|
||||
headers: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="The query header. Note that we will add "
|
||||
"'Content-Type: application/json' to be the header for sure "
|
||||
"because we only deal with requests body in JSON.",
|
||||
)
|
||||
qps: Optional[int] = Field(
|
||||
default=None,
|
||||
description="The maximum number of requests per second to avoid rate limit. "
|
||||
"If None, the request will be sent sequentially.",
|
||||
)
|
||||
max_retries: int = Field(
|
||||
default=0,
|
||||
description="The maximum number of retries per request in the event of failures.",
|
||||
)
|
||||
base_retry_wait_time_in_s: float = Field(
|
||||
default=1,
|
||||
description="The base wait time for a retry during exponential backoff.",
|
||||
)
|
||||
# Since `session_factory` is a callable, we use type Any to avoid pydantic serialization issues
|
||||
session_factory: Optional[Any] = Field(
|
||||
default=None,
|
||||
description="Optional session factory to be used for initializing a client session. Type: Callable[[], ClientSession]",
|
||||
# exclude from JSON serialization since `session_factory` is a callable
|
||||
exclude=True,
|
||||
)
|
||||
http_request_stage: Any = Field(
|
||||
default=True,
|
||||
description="Http request stage config (bool | dict | HttpRequestStageConfig).",
|
||||
)
|
||||
|
||||
|
||||
def build_http_request_processor(
|
||||
config: HttpRequestProcessorConfig,
|
||||
preprocess: Optional[UserDefinedFunction] = None,
|
||||
postprocess: Optional[UserDefinedFunction] = None,
|
||||
preprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
postprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> Processor:
|
||||
"""Construct a Processor and configure stages.
|
||||
|
||||
Args:
|
||||
config: The configuration for the processor.
|
||||
preprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a preprocessed row (dict). The output row must contain the
|
||||
required fields for the following processing stages.
|
||||
postprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a postprocessed row (dict).
|
||||
preprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
preprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
postprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
postprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
|
||||
Returns:
|
||||
The constructed processor.
|
||||
"""
|
||||
|
||||
# Prepare processor defaults for merging into stage configs
|
||||
processor_defaults = {
|
||||
"batch_size": config.batch_size,
|
||||
"concurrency": config.concurrency,
|
||||
}
|
||||
|
||||
# Resolve and build HttpRequestStage if enabled
|
||||
http_request_stage_cfg = resolve_stage_config(
|
||||
config.http_request_stage,
|
||||
HttpRequestStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
|
||||
if not http_request_stage_cfg.enabled:
|
||||
raise ValueError(
|
||||
"The HTTP request stage is required and cannot be disabled in HttpRequestProcessorConfig."
|
||||
)
|
||||
|
||||
stages = [
|
||||
HttpRequestStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
url=config.url,
|
||||
additional_header=config.headers,
|
||||
qps=config.qps,
|
||||
max_retries=config.max_retries,
|
||||
base_retry_wait_time_in_s=config.base_retry_wait_time_in_s,
|
||||
session_factory=config.session_factory,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(http_request_stage_cfg),
|
||||
)
|
||||
]
|
||||
telemetry_agent = get_or_create_telemetry_agent()
|
||||
telemetry_agent.push_telemetry_report(
|
||||
BatchModelTelemetry(
|
||||
# Hash the target URL so distinct endpoints stay separate in the
|
||||
# dedup key without the cleartext URL reaching the head-node actor.
|
||||
model_id_hash=hashlib.sha256(config.url.encode("utf-8")).hexdigest(),
|
||||
processor_config_name=type(config).__name__,
|
||||
batch_size=config.batch_size,
|
||||
concurrency=config.concurrency,
|
||||
)
|
||||
)
|
||||
processor = Processor(
|
||||
config,
|
||||
stages,
|
||||
preprocess=preprocess,
|
||||
postprocess=postprocess,
|
||||
preprocess_map_kwargs=preprocess_map_kwargs,
|
||||
postprocess_map_kwargs=postprocess_map_kwargs,
|
||||
)
|
||||
return processor
|
||||
|
||||
|
||||
ProcessorBuilder.register(HttpRequestProcessorConfig, build_http_request_processor)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""The processor that runs serve deployment."""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ray.data import ActorPoolStrategy
|
||||
from ray.data.block import UserDefinedFunction
|
||||
from ray.llm._internal.batch.processor.base import (
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
ProcessorConfig,
|
||||
)
|
||||
from ray.llm._internal.batch.stages import (
|
||||
ServeDeploymentStage,
|
||||
)
|
||||
|
||||
|
||||
class ServeDeploymentProcessorConfig(ProcessorConfig):
|
||||
"""The configuration for the serve deployment processor."""
|
||||
|
||||
# Configurations used to build the serve deployment
|
||||
deployment_name: str = Field(
|
||||
description="The name of the serve deployment to use.",
|
||||
)
|
||||
app_name: str = Field(
|
||||
description="The name of the serve application to use.",
|
||||
default="default",
|
||||
)
|
||||
dtype_mapping: Dict[str, Type[Any]] = Field(
|
||||
description="A dictionary mapping data type names to their corresponding request classes for the serve deployment.",
|
||||
default=None,
|
||||
)
|
||||
should_continue_on_error: bool = Field(
|
||||
default=False,
|
||||
description="If True, continue processing when inference fails for a row "
|
||||
"instead of raising an exception. Failed rows will have a non-null "
|
||||
"'__inference_error__' column containing the error message. Error rows "
|
||||
"bypass postprocess. If False (default), any inference error raises.",
|
||||
)
|
||||
request_timeout_s: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="Optional per-request timeout in seconds. When set, a request "
|
||||
"that does not return within this many seconds raises TimeoutError instead "
|
||||
"of blocking indefinitely (e.g. when replicas are saturated). TimeoutError "
|
||||
"is recoverable, so combine with should_continue_on_error=True to drop the "
|
||||
"slow row as an error instead of failing the job. If None (default), "
|
||||
"requests wait indefinitely.",
|
||||
)
|
||||
|
||||
|
||||
def build_serve_deployment_processor(
|
||||
config: ServeDeploymentProcessorConfig,
|
||||
preprocess: Optional[UserDefinedFunction] = None,
|
||||
postprocess: Optional[UserDefinedFunction] = None,
|
||||
preprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
postprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> Processor:
|
||||
"""Construct a processor that runs a serve deployment.
|
||||
|
||||
Args:
|
||||
config: The configuration for the processor.
|
||||
preprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a preprocessed row (dict). The output row must contain the
|
||||
required fields for the following processing stages.
|
||||
postprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a postprocessed row (dict).
|
||||
preprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
preprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
postprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
postprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
|
||||
Returns:
|
||||
The constructed processor.
|
||||
"""
|
||||
stages = [
|
||||
ServeDeploymentStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
deployment_name=config.deployment_name,
|
||||
app_name=config.app_name,
|
||||
dtype_mapping=config.dtype_mapping,
|
||||
should_continue_on_error=config.should_continue_on_error,
|
||||
request_timeout_s=config.request_timeout_s,
|
||||
),
|
||||
map_batches_kwargs=dict(
|
||||
compute=ActorPoolStrategy(
|
||||
**config.get_concurrency(autoscaling_enabled=False),
|
||||
)
|
||||
),
|
||||
)
|
||||
]
|
||||
# TODO (Kourosh): Add telemetry for ServeDeploymentStage
|
||||
processor = Processor(
|
||||
config,
|
||||
stages,
|
||||
preprocess=preprocess,
|
||||
postprocess=postprocess,
|
||||
preprocess_map_kwargs=preprocess_map_kwargs,
|
||||
postprocess_map_kwargs=postprocess_map_kwargs,
|
||||
)
|
||||
return processor
|
||||
|
||||
|
||||
ProcessorBuilder.register(
|
||||
ServeDeploymentProcessorConfig, build_serve_deployment_processor
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""The SGLang engine processor."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import transformers
|
||||
from pydantic import Field, root_validator
|
||||
|
||||
import ray
|
||||
from ray.data.block import UserDefinedFunction
|
||||
from ray.llm._internal.batch.constants import SGLangTaskType, TypeSGLangTaskType
|
||||
from ray.llm._internal.batch.observability.usage_telemetry.usage import (
|
||||
BatchModelTelemetry,
|
||||
TelemetryAgent,
|
||||
get_or_create_telemetry_agent,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.base import (
|
||||
OfflineProcessorConfig,
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.utils import (
|
||||
build_cpu_stage_map_kwargs,
|
||||
get_value_or_fallback,
|
||||
)
|
||||
from ray.llm._internal.batch.stages import (
|
||||
ChatTemplateStage,
|
||||
DetokenizeStage,
|
||||
SGLangEngineStage,
|
||||
TokenizeStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.configs import (
|
||||
ChatTemplateStageConfig,
|
||||
DetokenizeStageConfig,
|
||||
TokenizerStageConfig,
|
||||
resolve_stage_config,
|
||||
)
|
||||
from ray.llm._internal.common.observability.telemetry_utils import DEFAULT_GPU_TYPE
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
NodeModelDownloadable,
|
||||
download_model_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_MODEL_ARCHITECTURE = "UNKNOWN_MODEL_ARCHITECTURE"
|
||||
|
||||
|
||||
class SGLangEngineProcessorConfig(OfflineProcessorConfig):
|
||||
"""The configuration for the SGLang engine processor."""
|
||||
|
||||
# SGLang stage configurations.
|
||||
engine_kwargs: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="The kwargs to pass to the SGLang engine. See "
|
||||
"https://docs.sglang.ai/backend/server_arguments.html "
|
||||
"for more details.",
|
||||
)
|
||||
task_type: TypeSGLangTaskType = Field(
|
||||
default=SGLangTaskType.GENERATE,
|
||||
description="The task type to use. If not specified, will use "
|
||||
"'generate' by default.",
|
||||
)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_task_type(cls, values):
|
||||
task_type = values.get("task_type", SGLangTaskType.GENERATE)
|
||||
if task_type not in SGLangTaskType.values():
|
||||
raise ValueError(f"Invalid task type: {task_type}")
|
||||
|
||||
engine_kwargs = values.get("engine_kwargs", {})
|
||||
engine_kwargs_task = engine_kwargs.get("task", "")
|
||||
if engine_kwargs_task != task_type:
|
||||
logger.warning(
|
||||
"The task set in engine kwargs (%s) is different from the "
|
||||
"stage (%s). Overriding the task in engine kwargs to %s.",
|
||||
engine_kwargs_task,
|
||||
task_type,
|
||||
task_type,
|
||||
)
|
||||
engine_kwargs["task"] = task_type
|
||||
values["engine_kwargs"] = engine_kwargs
|
||||
return values
|
||||
|
||||
|
||||
def build_sglang_engine_processor(
|
||||
config: SGLangEngineProcessorConfig,
|
||||
chat_template_kwargs: Optional[Dict[str, Any]] = None,
|
||||
preprocess: Optional[UserDefinedFunction] = None,
|
||||
postprocess: Optional[UserDefinedFunction] = None,
|
||||
preprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
postprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
telemetry_agent: Optional[TelemetryAgent] = None,
|
||||
) -> Processor:
|
||||
"""Construct a Processor and configure stages.
|
||||
|
||||
Args:
|
||||
config: The configuration for the processor.
|
||||
chat_template_kwargs: The optional kwargs to pass to apply_chat_template.
|
||||
preprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a preprocessed row (dict). The output row must contain the
|
||||
required fields for the following processing stages.
|
||||
postprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a postprocessed row (dict).
|
||||
preprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
preprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
postprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
postprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
telemetry_agent: An optional telemetry agent for collecting usage telemetry.
|
||||
|
||||
Returns:
|
||||
The constructed processor.
|
||||
"""
|
||||
ray.init(runtime_env=config.runtime_env, ignore_reinit_error=True)
|
||||
|
||||
stages = []
|
||||
|
||||
# Prepare processor defaults for merging into stage configs
|
||||
trust_remote_code = config.engine_kwargs.get("trust_remote_code", False)
|
||||
processor_defaults = {
|
||||
"batch_size": config.batch_size,
|
||||
"concurrency": config.concurrency,
|
||||
"runtime_env": config.runtime_env,
|
||||
"model_source": config.model_source,
|
||||
}
|
||||
|
||||
# Resolve and build ChatTemplateStage if enabled
|
||||
chat_template_stage_cfg = resolve_stage_config(
|
||||
config.chat_template_stage,
|
||||
ChatTemplateStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if chat_template_stage_cfg.enabled:
|
||||
stages.append(
|
||||
ChatTemplateStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=chat_template_stage_cfg.model_source,
|
||||
chat_template=get_value_or_fallback(
|
||||
chat_template_stage_cfg.chat_template, config.chat_template
|
||||
),
|
||||
chat_template_kwargs=get_value_or_fallback(
|
||||
chat_template_stage_cfg.chat_template_kwargs,
|
||||
chat_template_kwargs,
|
||||
),
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(chat_template_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve and build TokenizeStage if enabled
|
||||
tokenize_stage_cfg = resolve_stage_config(
|
||||
getattr(config, "tokenize_stage", config.tokenize),
|
||||
TokenizerStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if tokenize_stage_cfg.enabled:
|
||||
stages.append(
|
||||
TokenizeStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=tokenize_stage_cfg.model_source,
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(tokenize_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# Core stage -- the SGLang engine.
|
||||
stages.append(
|
||||
SGLangEngineStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=config.model_source,
|
||||
engine_kwargs=config.engine_kwargs,
|
||||
task_type=config.task_type,
|
||||
max_pending_requests=config.max_pending_requests,
|
||||
),
|
||||
map_batches_kwargs=dict(
|
||||
zero_copy_batch=True,
|
||||
# The number of running replicas. This is a deprecated field, but
|
||||
# we need to set `max_tasks_in_flight_per_actor` through `compute`,
|
||||
# which initiates enough many overlapping UDF calls per actor, to
|
||||
# saturate `max_concurrency`.
|
||||
compute=ray.data.ActorPoolStrategy(
|
||||
**config.get_concurrency(autoscaling_enabled=True),
|
||||
max_tasks_in_flight_per_actor=config.max_tasks_in_flight_per_actor,
|
||||
),
|
||||
# The number of running batches "per actor" in Ray Core level.
|
||||
# This is used to make sure we overlap batches to avoid the tail
|
||||
# latency of each batch.
|
||||
max_concurrency=config.max_concurrent_batches,
|
||||
accelerator_type=config.accelerator_type,
|
||||
runtime_env=config.runtime_env,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve and build DetokenizeStage if enabled
|
||||
detokenize_stage_cfg = resolve_stage_config(
|
||||
getattr(config, "detokenize_stage", config.detokenize),
|
||||
DetokenizeStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if detokenize_stage_cfg.enabled:
|
||||
stages.append(
|
||||
DetokenizeStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=detokenize_stage_cfg.model_source,
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(detokenize_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# Download model files for telemetry before engine init.
|
||||
# Use EXCLUDE_SAFETENSORS for trust_remote_code models so custom .py config
|
||||
# files are available locally.
|
||||
try:
|
||||
download_mode = (
|
||||
NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
if trust_remote_code
|
||||
else NodeModelDownloadable.TOKENIZER_ONLY
|
||||
)
|
||||
model_path_or_id = download_model_files(
|
||||
model_id=config.model_source,
|
||||
mirror_config=None,
|
||||
download_model=download_mode,
|
||||
download_extra_files=False,
|
||||
)
|
||||
|
||||
hf_config = transformers.AutoConfig.from_pretrained(
|
||||
model_path_or_id,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
except Exception as e:
|
||||
# Failed to retrieve HuggingFace config for telemetry purposes.
|
||||
# This is non-fatal: we fall back to DEFAULT_MODEL_ARCHITECTURE for telemetry.
|
||||
# The actual model loading happens later in SGLang, which may support models
|
||||
# that aren't available via HuggingFace's AutoConfig.
|
||||
logger.warning(
|
||||
"Failed to retrieve HuggingFace config for %s: %s",
|
||||
config.model_source,
|
||||
e,
|
||||
)
|
||||
hf_config = None
|
||||
|
||||
architectures = getattr(hf_config, "architectures", [])
|
||||
architecture = architectures[0] if architectures else DEFAULT_MODEL_ARCHITECTURE
|
||||
|
||||
telemetry_agent = get_or_create_telemetry_agent()
|
||||
telemetry_agent.push_telemetry_report(
|
||||
BatchModelTelemetry(
|
||||
model_id_hash=hashlib.sha256(
|
||||
config.model_source.encode("utf-8")
|
||||
).hexdigest(),
|
||||
processor_config_name=type(config).__name__,
|
||||
model_architecture=architecture,
|
||||
batch_size=config.batch_size,
|
||||
accelerator_type=config.accelerator_type or DEFAULT_GPU_TYPE,
|
||||
concurrency=config.concurrency,
|
||||
task_type=config.task_type,
|
||||
tensor_parallel_size=config.engine_kwargs.get("tp_size", 1),
|
||||
data_parallel_size=config.engine_kwargs.get("dp_size", 1),
|
||||
)
|
||||
)
|
||||
|
||||
processor = Processor(
|
||||
config,
|
||||
stages,
|
||||
preprocess=preprocess,
|
||||
postprocess=postprocess,
|
||||
preprocess_map_kwargs=preprocess_map_kwargs,
|
||||
postprocess_map_kwargs=postprocess_map_kwargs,
|
||||
)
|
||||
return processor
|
||||
|
||||
|
||||
ProcessorBuilder.register(SGLangEngineProcessorConfig, build_sglang_engine_processor)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Shared utility functions for processor builders."""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from ray.data import ActorPoolStrategy
|
||||
from ray.llm._internal.batch.stages.configs import _StageConfigBase
|
||||
|
||||
|
||||
def get_value_or_fallback(value: Any, fallback: Any) -> Any:
|
||||
"""Return value if not None, otherwise return fallback."""
|
||||
return value if value is not None else fallback
|
||||
|
||||
|
||||
def extract_resource_kwargs(
|
||||
runtime_env: Optional[Dict[str, Any]],
|
||||
num_cpus: Optional[float],
|
||||
memory: Optional[float],
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract non-None resource kwargs for map_batches."""
|
||||
kwargs = {}
|
||||
if runtime_env is not None:
|
||||
kwargs["runtime_env"] = runtime_env
|
||||
if num_cpus is not None:
|
||||
kwargs["num_cpus"] = num_cpus
|
||||
if memory is not None:
|
||||
kwargs["memory"] = memory
|
||||
return kwargs
|
||||
|
||||
|
||||
def normalize_cpu_stage_concurrency(
|
||||
concurrency: Optional[Union[int, Tuple[int, int]]]
|
||||
) -> Dict[str, int]:
|
||||
"""Normalize concurrency for CPU stages (int -> (1, int) for autoscaling)."""
|
||||
if concurrency is None:
|
||||
return {"size": 1} # Default to minimal autoscaling pool
|
||||
if isinstance(concurrency, int):
|
||||
return {"min_size": 1, "max_size": concurrency}
|
||||
return {
|
||||
"min_size": concurrency[0],
|
||||
"max_size": concurrency[1],
|
||||
}
|
||||
|
||||
|
||||
def build_cpu_stage_map_kwargs(
|
||||
stage_cfg: _StageConfigBase,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build map_batches_kwargs for CPU stages."""
|
||||
concurrency = normalize_cpu_stage_concurrency(stage_cfg.concurrency)
|
||||
return dict(
|
||||
zero_copy_batch=True,
|
||||
compute=ActorPoolStrategy(**concurrency),
|
||||
batch_size=stage_cfg.batch_size,
|
||||
**extract_resource_kwargs(
|
||||
stage_cfg.runtime_env,
|
||||
stage_cfg.num_cpus,
|
||||
stage_cfg.memory,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""The vLLM engine processor."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import transformers
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
import ray
|
||||
from ray.data.block import UserDefinedFunction
|
||||
from ray.llm._internal.batch.constants import TypeVLLMTaskType, vLLMTaskType
|
||||
from ray.llm._internal.batch.observability.usage_telemetry.usage import (
|
||||
BatchModelTelemetry,
|
||||
TelemetryAgent,
|
||||
get_or_create_telemetry_agent,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.base import (
|
||||
OfflineProcessorConfig,
|
||||
Processor,
|
||||
ProcessorBuilder,
|
||||
)
|
||||
from ray.llm._internal.batch.processor.utils import (
|
||||
build_cpu_stage_map_kwargs,
|
||||
get_value_or_fallback,
|
||||
)
|
||||
from ray.llm._internal.batch.stages import (
|
||||
ChatTemplateStage,
|
||||
DetokenizeStage,
|
||||
PrepareMultimodalStage,
|
||||
TokenizeStage,
|
||||
vLLMEngineStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.configs import (
|
||||
ChatTemplateStageConfig,
|
||||
DetokenizeStageConfig,
|
||||
PrepareMultimodalStageConfig,
|
||||
TokenizerStageConfig,
|
||||
resolve_stage_config,
|
||||
)
|
||||
from ray.llm._internal.common.observability.telemetry_utils import DEFAULT_GPU_TYPE
|
||||
from ray.llm._internal.common.placement import PlacementGroupConfig
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
STREAMING_LOAD_FORMATS,
|
||||
NodeModelDownloadable,
|
||||
download_model_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_MODEL_ARCHITECTURE = "UNKNOWN_MODEL_ARCHITECTURE"
|
||||
|
||||
|
||||
class vLLMEngineProcessorConfig(OfflineProcessorConfig):
|
||||
"""The configuration for the vLLM engine processor."""
|
||||
|
||||
# vLLM stage configurations.
|
||||
engine_kwargs: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="The kwargs to pass to the vLLM engine. See "
|
||||
"https://docs.vllm.ai/en/latest/serving/engine_args.html "
|
||||
"for more details.",
|
||||
)
|
||||
task_type: TypeVLLMTaskType = Field(
|
||||
default=vLLMTaskType.GENERATE,
|
||||
description="The task type to use. If not specified, will use "
|
||||
"'generate' by default.",
|
||||
)
|
||||
log_engine_metrics: bool = Field(
|
||||
default=True,
|
||||
description="Enable vLLM engine metrics export via Ray's Prometheus endpoint. "
|
||||
"When enabled, metrics like prefix cache hit rate, TTFT, TPOT, KV cache "
|
||||
"utilization, and scheduler state are available at Ray's metrics endpoint. "
|
||||
"Requires Ray to be initialized with _metrics_export_port "
|
||||
"(e.g., ray.init(_metrics_export_port=8080)).",
|
||||
)
|
||||
# LoRA configurations.
|
||||
dynamic_lora_loading_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The path to the dynamic LoRA adapter. It is expected "
|
||||
"to hold subfolders each for a different lora checkpoint. If not "
|
||||
"specified and LoRA is enabled, then the 'model' in LoRA "
|
||||
"requests will be interpreted as model ID used by HF transformers.",
|
||||
)
|
||||
# Custom placement group config for TP/PP.
|
||||
placement_group_config: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Ray placement group configuration for scheduling vLLM engine workers. "
|
||||
"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'}.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_task_type(cls, values):
|
||||
task_type = values.get("task_type", vLLMTaskType.GENERATE)
|
||||
if task_type not in vLLMTaskType.values():
|
||||
raise ValueError(f"Invalid task type: {task_type}")
|
||||
|
||||
engine_kwargs = values.get("engine_kwargs", {})
|
||||
engine_kwargs_task_type = engine_kwargs.get("task_type", "")
|
||||
if engine_kwargs_task_type != task_type:
|
||||
if engine_kwargs_task_type:
|
||||
logger.warning(
|
||||
"The task_type set in engine kwargs (%s) is different from the "
|
||||
"config (%s). Overriding the task_type in engine kwargs to %s.",
|
||||
engine_kwargs_task_type,
|
||||
task_type,
|
||||
task_type,
|
||||
)
|
||||
engine_kwargs["task_type"] = task_type
|
||||
values["engine_kwargs"] = engine_kwargs
|
||||
return values
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
def build_vllm_engine_processor(
|
||||
config: vLLMEngineProcessorConfig,
|
||||
chat_template_kwargs: Optional[Dict[str, Any]] = None,
|
||||
preprocess: Optional[UserDefinedFunction] = None,
|
||||
postprocess: Optional[UserDefinedFunction] = None,
|
||||
preprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
postprocess_map_kwargs: Optional[Dict[str, Any]] = None,
|
||||
telemetry_agent: Optional[TelemetryAgent] = None,
|
||||
) -> Processor:
|
||||
"""Construct a Processor and configure stages.
|
||||
|
||||
Args:
|
||||
config: The configuration for the processor.
|
||||
chat_template_kwargs: The optional kwargs to pass to apply_chat_template.
|
||||
preprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a preprocessed row (dict). The output row must contain the
|
||||
required fields for the following processing stages.
|
||||
postprocess: An optional lambda function that takes a row (dict) as input
|
||||
and returns a postprocessed row (dict).
|
||||
preprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
preprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
postprocess_map_kwargs: Optional kwargs to pass to Dataset.map() for the
|
||||
postprocess stage (e.g., num_cpus, memory, concurrency).
|
||||
telemetry_agent: An optional telemetry agent for collecting usage telemetry.
|
||||
|
||||
Returns:
|
||||
The constructed processor.
|
||||
"""
|
||||
ray.init(runtime_env=config.runtime_env, ignore_reinit_error=True)
|
||||
|
||||
stages = []
|
||||
|
||||
# Prepare processor defaults for merging into stage configs
|
||||
trust_remote_code = config.engine_kwargs.get("trust_remote_code", False)
|
||||
processor_defaults = {
|
||||
"batch_size": config.batch_size,
|
||||
"concurrency": config.concurrency,
|
||||
"runtime_env": config.runtime_env,
|
||||
"model_source": config.model_source,
|
||||
}
|
||||
|
||||
# Resolve and build PrepareMultimodalStage if enabled.
|
||||
prepare_multimodal_stage_cfg = resolve_stage_config(
|
||||
config.prepare_multimodal_stage,
|
||||
PrepareMultimodalStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
|
||||
if prepare_multimodal_stage_cfg.enabled:
|
||||
base_model_config_kwargs = (
|
||||
prepare_multimodal_stage_cfg.model_config_kwargs or {}
|
||||
)
|
||||
# Respect the model source from the processor
|
||||
model_config_kwargs = {
|
||||
**base_model_config_kwargs,
|
||||
"model": processor_defaults.get("model_source"),
|
||||
}
|
||||
stages.append(
|
||||
PrepareMultimodalStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model_config_kwargs=model_config_kwargs,
|
||||
chat_template_content_format=prepare_multimodal_stage_cfg.chat_template_content_format,
|
||||
apply_sys_msg_formatting=prepare_multimodal_stage_cfg.apply_sys_msg_formatting,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(
|
||||
prepare_multimodal_stage_cfg
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve and build ChatTemplateStage if enabled
|
||||
chat_template_stage_cfg = resolve_stage_config(
|
||||
getattr(config, "chat_template_stage", config.apply_chat_template),
|
||||
ChatTemplateStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if chat_template_stage_cfg.enabled:
|
||||
stages.append(
|
||||
ChatTemplateStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=chat_template_stage_cfg.model_source,
|
||||
chat_template=get_value_or_fallback(
|
||||
chat_template_stage_cfg.chat_template, config.chat_template
|
||||
),
|
||||
chat_template_kwargs=get_value_or_fallback(
|
||||
chat_template_stage_cfg.chat_template_kwargs,
|
||||
chat_template_kwargs,
|
||||
),
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(chat_template_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve and build TokenizeStage if enabled
|
||||
tokenize_stage_cfg = resolve_stage_config(
|
||||
getattr(config, "tokenize_stage", config.tokenize),
|
||||
TokenizerStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if tokenize_stage_cfg.enabled:
|
||||
stages.append(
|
||||
TokenizeStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=tokenize_stage_cfg.model_source,
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(tokenize_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# Core stage -- the vLLM engine.
|
||||
|
||||
stages.append(
|
||||
vLLMEngineStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
batch_size=config.batch_size,
|
||||
max_concurrent_batches=config.max_concurrent_batches,
|
||||
model=config.model_source,
|
||||
engine_kwargs=config.engine_kwargs,
|
||||
task_type=config.task_type,
|
||||
max_pending_requests=config.max_pending_requests,
|
||||
dynamic_lora_loading_path=config.dynamic_lora_loading_path,
|
||||
placement_group_config=config.placement_group_config,
|
||||
should_continue_on_error=config.should_continue_on_error,
|
||||
log_engine_metrics=config.log_engine_metrics,
|
||||
),
|
||||
map_batches_kwargs=dict(
|
||||
zero_copy_batch=True,
|
||||
# The number of running replicas. This is a deprecated field, but
|
||||
# we need to set `max_tasks_in_flight_per_actor` through `compute`,
|
||||
# which initiates enough many overlapping UDF calls per actor, to
|
||||
# saturate `max_concurrency`.
|
||||
compute=ray.data.ActorPoolStrategy(
|
||||
**config.get_concurrency(autoscaling_enabled=True),
|
||||
max_tasks_in_flight_per_actor=config.max_tasks_in_flight_per_actor,
|
||||
),
|
||||
# The number of running batches "per actor" in Ray Core level.
|
||||
# This is used to make sure we overlap batches to avoid the tail
|
||||
# latency of each batch.
|
||||
max_concurrency=config.max_concurrent_batches,
|
||||
accelerator_type=config.accelerator_type,
|
||||
runtime_env=config.runtime_env,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve and build DetokenizeStage if enabled
|
||||
detokenize_stage_cfg = resolve_stage_config(
|
||||
getattr(config, "detokenize_stage", config.detokenize),
|
||||
DetokenizeStageConfig,
|
||||
processor_defaults,
|
||||
)
|
||||
if detokenize_stage_cfg.enabled:
|
||||
stages.append(
|
||||
DetokenizeStage(
|
||||
fn_constructor_kwargs=dict(
|
||||
model=detokenize_stage_cfg.model_source,
|
||||
trust_remote_code=trust_remote_code,
|
||||
),
|
||||
map_batches_kwargs=build_cpu_stage_map_kwargs(detokenize_stage_cfg),
|
||||
)
|
||||
)
|
||||
|
||||
# We download the config files here so that we can report the underlying architecture to the telemetry system.
|
||||
# This should be a lightweight operation.
|
||||
# Use EXCLUDE_SAFETENSORS for streaming formats or trust_remote_code models,
|
||||
# since custom model architectures require Python config files to be downloaded.
|
||||
if config.engine_kwargs.get(
|
||||
"load_format", None
|
||||
) in STREAMING_LOAD_FORMATS or config.engine_kwargs.get("trust_remote_code", False):
|
||||
download_model_mode = NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
else:
|
||||
download_model_mode = NodeModelDownloadable.TOKENIZER_ONLY
|
||||
model_path = download_model_files(
|
||||
model_id=config.model_source,
|
||||
mirror_config=None,
|
||||
download_model=download_model_mode,
|
||||
download_extra_files=False,
|
||||
)
|
||||
|
||||
try:
|
||||
hf_config = transformers.AutoConfig.from_pretrained(
|
||||
model_path,
|
||||
trust_remote_code=config.engine_kwargs.get("trust_remote_code", False),
|
||||
)
|
||||
except Exception:
|
||||
# Failed to retrieve HuggingFace config for telemetry purposes.
|
||||
# This is non-fatal: we fall back to DEFAULT_MODEL_ARCHITECTURE for telemetry.
|
||||
# The actual model loading happens later in vLLM, which may support models
|
||||
# that aren't available via HuggingFace's AutoConfig.
|
||||
logger.warning(
|
||||
f"Failed to retrieve HuggingFace config for {config.model_source}"
|
||||
)
|
||||
hf_config = None
|
||||
|
||||
architectures = getattr(hf_config, "architectures", [])
|
||||
architecture = architectures[0] if architectures else DEFAULT_MODEL_ARCHITECTURE
|
||||
|
||||
telemetry_agent = get_or_create_telemetry_agent()
|
||||
telemetry_agent.push_telemetry_report(
|
||||
BatchModelTelemetry(
|
||||
model_id_hash=hashlib.sha256(
|
||||
config.model_source.encode("utf-8")
|
||||
).hexdigest(),
|
||||
processor_config_name=type(config).__name__,
|
||||
model_architecture=architecture,
|
||||
batch_size=config.batch_size,
|
||||
accelerator_type=config.accelerator_type or DEFAULT_GPU_TYPE,
|
||||
concurrency=config.concurrency,
|
||||
task_type=config.task_type,
|
||||
pipeline_parallel_size=config.engine_kwargs.get(
|
||||
"pipeline_parallel_size", 1
|
||||
),
|
||||
tensor_parallel_size=config.engine_kwargs.get("tensor_parallel_size", 1),
|
||||
data_parallel_size=config.engine_kwargs.get("data_parallel_size", 1),
|
||||
)
|
||||
)
|
||||
|
||||
processor = Processor(
|
||||
config,
|
||||
stages,
|
||||
preprocess=preprocess,
|
||||
postprocess=postprocess,
|
||||
preprocess_map_kwargs=preprocess_map_kwargs,
|
||||
postprocess_map_kwargs=postprocess_map_kwargs,
|
||||
)
|
||||
return processor
|
||||
|
||||
|
||||
ProcessorBuilder.register(vLLMEngineProcessorConfig, build_vllm_engine_processor)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Lazy re-exports for batch stages.
|
||||
|
||||
The stage modules transitively import heavy ML dependencies (transformers,
|
||||
vllm, sglang, mistral_common, etc.). Eagerly importing them all here causes
|
||||
even lightweight processors (e.g. ``HttpRequestProcessor``) to pay the cost of
|
||||
loading the entire ML stack, which is both slow and pulls in optional
|
||||
dependencies that may not be installed.
|
||||
|
||||
We use PEP 562 ``__getattr__`` to defer those imports until first access so
|
||||
that ``from ray.llm._internal.batch.stages import X`` only loads the submodule
|
||||
that defines ``X``.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ray.llm._internal.batch.stages.base import (
|
||||
StatefulStage,
|
||||
wrap_postprocess,
|
||||
wrap_preprocess,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.configs import (
|
||||
ChatTemplateStageConfig,
|
||||
DetokenizeStageConfig,
|
||||
PrepareMultimodalStageConfig,
|
||||
TokenizerStageConfig,
|
||||
)
|
||||
|
||||
# Mapping of public attribute name -> (submodule, attribute name in submodule).
|
||||
# Each entry here is a stage class that is expensive to import (because the
|
||||
# defining submodule pulls in transformers / vllm / sglang / mistral_common /
|
||||
# pillow / etc.). They are loaded on first access via ``__getattr__`` below.
|
||||
_LAZY_ATTRS = {
|
||||
"ChatTemplateStage": ("chat_template_stage", "ChatTemplateStage"),
|
||||
"HttpRequestStage": ("http_request_stage", "HttpRequestStage"),
|
||||
"PrepareMultimodalStage": ("prepare_multimodal_stage", "PrepareMultimodalStage"),
|
||||
"ServeDeploymentStage": ("serve_deployment_stage", "ServeDeploymentStage"),
|
||||
"SGLangEngineStage": ("sglang_engine_stage", "SGLangEngineStage"),
|
||||
"TokenizeStage": ("tokenize_stage", "TokenizeStage"),
|
||||
"DetokenizeStage": ("tokenize_stage", "DetokenizeStage"),
|
||||
"vLLMEngineStage": ("vllm_engine_stage", "vLLMEngineStage"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazily import heavy stage classes on first access (PEP 562)."""
|
||||
try:
|
||||
submodule, attr = _LAZY_ATTRS[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(f"{__name__}.{submodule}")
|
||||
value = getattr(module, attr)
|
||||
# Cache on the package so subsequent lookups skip __getattr__.
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()).union(_LAZY_ATTRS))
|
||||
|
||||
|
||||
# Help static type checkers (and IDEs) see the lazily-exported names. These
|
||||
# imports are never executed at runtime, so they do not reintroduce the heavy
|
||||
# dependencies.
|
||||
if TYPE_CHECKING:
|
||||
from ray.llm._internal.batch.stages.chat_template_stage import ( # noqa: F401
|
||||
ChatTemplateStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.http_request_stage import ( # noqa: F401
|
||||
HttpRequestStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.prepare_multimodal_stage import ( # noqa: F401
|
||||
PrepareMultimodalStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.serve_deployment_stage import ( # noqa: F401
|
||||
ServeDeploymentStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.sglang_engine_stage import ( # noqa: F401
|
||||
SGLangEngineStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.tokenize_stage import ( # noqa: F401
|
||||
DetokenizeStage,
|
||||
TokenizeStage,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.vllm_engine_stage import ( # noqa: F401
|
||||
vLLMEngineStage,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StatefulStage",
|
||||
"HttpRequestStage",
|
||||
"PrepareMultimodalStage",
|
||||
"ChatTemplateStage",
|
||||
"TokenizeStage",
|
||||
"DetokenizeStage",
|
||||
"vLLMEngineStage",
|
||||
"SGLangEngineStage",
|
||||
"ServeDeploymentStage",
|
||||
"wrap_preprocess",
|
||||
"wrap_postprocess",
|
||||
"ChatTemplateStageConfig",
|
||||
"DetokenizeStageConfig",
|
||||
"PrepareMultimodalStageConfig",
|
||||
"TokenizerStageConfig",
|
||||
]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""The base class for all stages."""
|
||||
import logging
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Type
|
||||
|
||||
import pyarrow
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ray.data.block import UserDefinedFunction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wrap_preprocess(
|
||||
fn: UserDefinedFunction,
|
||||
processor_data_column: str,
|
||||
) -> Callable:
|
||||
"""Wrap the preprocess function, so that the output schema of the
|
||||
preprocess is normalized to {processor_data_column: fn(row), other input columns}.
|
||||
|
||||
Args:
|
||||
fn: The function to be applied.
|
||||
processor_data_column: The internal data column name of the processor.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
|
||||
def _preprocess(row: dict[str, Any]) -> dict[str, Any]:
|
||||
# First put everything into processor_data_column.
|
||||
outputs = {processor_data_column: row}
|
||||
|
||||
# Then apply the preprocess function and add its outputs.
|
||||
preprocess_output = fn(row)
|
||||
outputs[processor_data_column].update(preprocess_output)
|
||||
return outputs
|
||||
|
||||
return _preprocess
|
||||
|
||||
|
||||
def wrap_postprocess(
|
||||
fn: UserDefinedFunction,
|
||||
processor_data_column: str,
|
||||
include_error_column: bool = False,
|
||||
) -> Callable:
|
||||
"""Wrap the postprocess function to remove the processor_data_column.
|
||||
Note that we fully rely on users to determine which columns to carry over.
|
||||
|
||||
Error rows (with __inference_error__ set) bypass the user's postprocess
|
||||
function and return directly with the error information preserved.
|
||||
|
||||
Args:
|
||||
fn: The function to be applied.
|
||||
processor_data_column: The internal data column name of the processor.
|
||||
include_error_column: If True, always include __inference_error__ in output
|
||||
(Empty string for success rows, error message for failures). This ensures
|
||||
consistent schema across all output rows.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
|
||||
def _postprocess(row: dict[str, Any]) -> dict[str, Any]:
|
||||
if processor_data_column not in row:
|
||||
raise ValueError(
|
||||
f"[Internal] {processor_data_column} not found in row {row}"
|
||||
)
|
||||
|
||||
data = row[processor_data_column]
|
||||
|
||||
# Error rows bypass user postprocess to avoid crashes when
|
||||
# expected output fields are missing. Return entire data dict
|
||||
# to preserve debugging info (e.g., prompt).
|
||||
if data.get("__inference_error__", "") != "":
|
||||
return data
|
||||
|
||||
result = fn(data)
|
||||
if include_error_column:
|
||||
result["__inference_error__"] = ""
|
||||
return result
|
||||
|
||||
return _postprocess
|
||||
|
||||
|
||||
class StatefulStageUDF:
|
||||
"""A stage UDF wrapper that processes the input and output columns
|
||||
before and after the UDF.
|
||||
|
||||
Args:
|
||||
data_column: The internal data column name of the processor. The
|
||||
__call__ method takes the data column as the input of the UDF
|
||||
method, and encapsulates the output of the UDF method into the data
|
||||
column for the next stage.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
"""
|
||||
|
||||
# The internal column name for the index of the row in the batch.
|
||||
# This is used to align the output of the UDF with the input batch.
|
||||
IDX_IN_BATCH_COLUMN: str = "__idx_in_batch"
|
||||
|
||||
def __init__(
|
||||
self, data_column: str, expected_input_keys: Optional[List[str]] = None
|
||||
):
|
||||
self.data_column = data_column
|
||||
self.expected_input_keys = set(expected_input_keys or [])
|
||||
|
||||
async def __call__(self, batch: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""A stage UDF wrapper that processes the input and output columns
|
||||
before and after the UDF.
|
||||
|
||||
The expected schema of "batch" is:
|
||||
{data_column: {
|
||||
dataset columns,
|
||||
other intermediate columns
|
||||
},
|
||||
...other metadata columns...,
|
||||
}.
|
||||
|
||||
The input of the UDF will then [dataset columns and other intermediate columns].
|
||||
In addition, while the output of the UDF depends on the UDF implementation,
|
||||
the output schema is expected to be
|
||||
{data_column: {
|
||||
dataset columns,
|
||||
other intermediate columns,
|
||||
UDF output columns (will override above columns if they have the same name)
|
||||
},
|
||||
...other metadata columns...,
|
||||
}.
|
||||
And this will become the input of the next stage.
|
||||
|
||||
Examples:
|
||||
Input dataset columns: {A, B, C}
|
||||
Preprocess: (lambda row: {"D": row["A"] + 1})
|
||||
Input:
|
||||
UDF input: {A, B, C}
|
||||
UDF output: {D}
|
||||
Output: {__data: {A, B, C, D}}
|
||||
Stage 1:
|
||||
Input: {__data: {A, B, C, D}}
|
||||
UDF input: {A, B, C, D}
|
||||
UDF output: {E}
|
||||
Output: {__data: {A, B, C, D, E}}
|
||||
Stage 2:
|
||||
Input: {__data: {A, B, C, D, E}}
|
||||
UDF input: {A, B, C, D, E}
|
||||
UDF output: {F, E} # E is in-place updated.
|
||||
Output: {__data: {A, B, C, D, E, F}}
|
||||
Postprocess: (lambda row: {"G": row["F"], "A": row["A"], "E": row["E"]})
|
||||
Input: {__data: {A, B, C, D, E, F}}
|
||||
UDF input: {A, B, C, D, E, F}
|
||||
UDF output: {G, A, E}
|
||||
Output: {G, A, E} # User chooses to keep G, A, E.
|
||||
|
||||
Args:
|
||||
batch: The input batch.
|
||||
|
||||
Yields:
|
||||
(str, Any): An async iterator of the outputs.
|
||||
|
||||
TODO(MARK): The yield type should be `Dict[str, Any]`, pydoclint is bugged in 0.8.4 (https://github.com/jsh9/pydoclint/issues/288)
|
||||
"""
|
||||
# Handle the case where the batch is empty.
|
||||
# FIXME: This should not happen.
|
||||
if isinstance(batch, pyarrow.lib.Table) and batch.num_rows == 0:
|
||||
yield {}
|
||||
return
|
||||
|
||||
if self.data_column not in batch:
|
||||
raise ValueError(
|
||||
f"[Internal] {self.data_column} not found in batch {batch}"
|
||||
)
|
||||
|
||||
inputs = batch.pop(self.data_column)
|
||||
if hasattr(inputs, "tolist"):
|
||||
inputs = inputs.tolist()
|
||||
|
||||
# Separate error rows from normal rows BEFORE validation. Error rows
|
||||
# (those with __inference_error__ set) bypass the UDF to avoid crashes
|
||||
# when expected fields are missing (e.g., generated_tokens for DetokenizeUDF).
|
||||
normal_rows = []
|
||||
error_row_indices = set()
|
||||
for idx, row in enumerate(inputs):
|
||||
if row.get("__inference_error__", "") != "":
|
||||
error_row_indices.add(idx)
|
||||
else:
|
||||
normal_rows.append(row)
|
||||
|
||||
# Validate only normal rows - error rows may be missing required keys
|
||||
self.validate_inputs(normal_rows)
|
||||
|
||||
# Assign the index of the row in the batch to the idx_in_batch_column.
|
||||
# This is because the UDF output may be out-of-order (if asyncio.as_completed
|
||||
# is used internally for example), and we need to carry over unused input
|
||||
# columns to the next stage. Thus, we use the row index in batch to match
|
||||
# the output of the UDF with the input.
|
||||
for idx, row in enumerate(inputs):
|
||||
row[self.IDX_IN_BATCH_COLUMN] = idx
|
||||
|
||||
# Collect all outputs first, then return them in the original order
|
||||
# This is a requirement set by https://github.com/ray-project/ray/pull/54190/
|
||||
not_output_rows = set(range(len(inputs))) - error_row_indices
|
||||
if normal_rows:
|
||||
async for output in self.udf(normal_rows):
|
||||
if self.IDX_IN_BATCH_COLUMN not in output:
|
||||
raise ValueError(
|
||||
"The output of the UDF must contain the column "
|
||||
f"{self.IDX_IN_BATCH_COLUMN}."
|
||||
)
|
||||
idx_in_batch = output.pop(self.IDX_IN_BATCH_COLUMN)
|
||||
if idx_in_batch not in not_output_rows:
|
||||
raise ValueError(
|
||||
f"The row {idx_in_batch} is output twice. "
|
||||
"This is likely due to the UDF is not one-to-one."
|
||||
)
|
||||
not_output_rows.remove(idx_in_batch)
|
||||
|
||||
# Add stage outputs to the data column of the row.
|
||||
inputs[idx_in_batch].pop(self.IDX_IN_BATCH_COLUMN)
|
||||
inputs[idx_in_batch].update(output)
|
||||
|
||||
if not_output_rows:
|
||||
raise ValueError(f"The rows {not_output_rows} are not output.")
|
||||
|
||||
# Clean up idx column from error rows (normal rows already cleaned above)
|
||||
for idx in error_row_indices:
|
||||
inputs[idx].pop(self.IDX_IN_BATCH_COLUMN, None)
|
||||
|
||||
# Return all updated inputs in the original order
|
||||
yield {self.data_column: inputs}
|
||||
|
||||
def validate_inputs(self, inputs: List[Dict[str, Any]]):
|
||||
"""Validate the inputs to make sure the required keys are present.
|
||||
|
||||
Args:
|
||||
inputs: The inputs.
|
||||
|
||||
Raises:
|
||||
ValueError: If the required keys are not found.
|
||||
"""
|
||||
for inp in inputs:
|
||||
input_keys = set(inp.keys())
|
||||
|
||||
if self.IDX_IN_BATCH_COLUMN in input_keys:
|
||||
raise ValueError(
|
||||
f"The input column {self.IDX_IN_BATCH_COLUMN} is reserved "
|
||||
"for internal use."
|
||||
)
|
||||
|
||||
if not self.expected_input_keys:
|
||||
continue
|
||||
|
||||
missing_required = self.expected_input_keys - input_keys
|
||||
if missing_required:
|
||||
raise ValueError(
|
||||
f"Required input keys {missing_required} not found at the input of "
|
||||
f"{self.__class__.__name__}. Input keys: {input_keys}"
|
||||
)
|
||||
|
||||
async def udf(self, rows: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
raise NotImplementedError("StageUDF must implement the udf method")
|
||||
|
||||
|
||||
class StatefulStage(BaseModel):
|
||||
"""
|
||||
A basic building block to compose a Processor.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = Field(
|
||||
description="The well-optimized stateful UDF for this stage."
|
||||
)
|
||||
fn_constructor_kwargs: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="The keyword arguments of the UDF constructor.",
|
||||
)
|
||||
map_batches_kwargs: Dict[str, Any] = Field(
|
||||
default_factory=lambda: {"concurrency": 1},
|
||||
description="The arguments of .map_batches(). Default {'concurrency': 1}.",
|
||||
)
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {}
|
||||
|
||||
def get_optional_input_keys(self) -> Dict[str, str]:
|
||||
"""The optional input keys of the stage and their descriptions."""
|
||||
return {}
|
||||
|
||||
def get_dataset_map_batches_kwargs(
|
||||
self,
|
||||
batch_size: int,
|
||||
data_column: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""We separate fn and fn_constructor_kwargs in Stage for better UX,
|
||||
so we combine them with other map_batches_kwargs together in this method.
|
||||
|
||||
Args:
|
||||
batch_size: The batch size set by the processor config.
|
||||
data_column: The data column name set by the processor.
|
||||
|
||||
Returns:
|
||||
The dataset map_batches kwargs.
|
||||
"""
|
||||
kwargs = self.map_batches_kwargs.copy()
|
||||
batch_size_in_kwargs = kwargs.get("batch_size", batch_size)
|
||||
if batch_size_in_kwargs != batch_size:
|
||||
logger.warning(
|
||||
"batch_size is set to %d in map_batches_kwargs, but it will be "
|
||||
"overridden by the batch size configured by the processor %d.",
|
||||
batch_size_in_kwargs,
|
||||
batch_size,
|
||||
)
|
||||
kwargs["batch_size"] = batch_size
|
||||
|
||||
kwargs.update({"fn_constructor_kwargs": self.fn_constructor_kwargs.copy()})
|
||||
if "data_column" in kwargs["fn_constructor_kwargs"]:
|
||||
raise ValueError(
|
||||
"'data_column' cannot be used as in fn_constructor_kwargs."
|
||||
)
|
||||
|
||||
kwargs["fn_constructor_kwargs"]["data_column"] = data_column
|
||||
kwargs["fn_constructor_kwargs"]["expected_input_keys"] = list(
|
||||
self.get_required_input_keys().keys()
|
||||
)
|
||||
return kwargs
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
validate_assignment = True
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Apply chat template stage"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type, Union
|
||||
|
||||
from ray.llm._internal.batch.stages.base import (
|
||||
StatefulStage,
|
||||
StatefulStageUDF,
|
||||
)
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
NodeModelDownloadable,
|
||||
download_model_files,
|
||||
)
|
||||
|
||||
|
||||
class ChatTemplateUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
model: str,
|
||||
chat_template: Optional[str] = None,
|
||||
chat_template_kwargs: Optional[Dict[str, Any]] = None,
|
||||
trust_remote_code: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the ChatTemplateUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
model: The model to use for the chat template.
|
||||
chat_template: The chat template in Jinja template format. This is
|
||||
usually not needed if the model checkpoint already contains the
|
||||
chat template.
|
||||
chat_template_kwargs: The optional kwargs to pass apply_chat_template.
|
||||
trust_remote_code: Whether to trust remote code when loading the model.
|
||||
"""
|
||||
from transformers import AutoProcessor
|
||||
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
|
||||
# NOTE: We always use processor instead of tokenizer in this stage,
|
||||
# because tokenizers of VLM models may not have chat template attribute.
|
||||
# However, this may not be a reliable solution, because processors and
|
||||
# tokenizers are not standardized across different models.
|
||||
# Use EXCLUDE_SAFETENSORS for trust_remote_code models to ensure
|
||||
# Python config files are downloaded.
|
||||
download_mode = (
|
||||
NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
if trust_remote_code
|
||||
else NodeModelDownloadable.TOKENIZER_ONLY
|
||||
)
|
||||
model_path = download_model_files(
|
||||
model_id=model,
|
||||
mirror_config=None,
|
||||
download_model=download_mode,
|
||||
download_extra_files=False,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from transformers.processing_utils import ProcessorMixin
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
|
||||
|
||||
self.processor: Union[
|
||||
"PreTrainedTokenizerBase", "ProcessorMixin"
|
||||
] = AutoProcessor.from_pretrained(
|
||||
model_path, trust_remote_code=trust_remote_code
|
||||
)
|
||||
self.chat_template = chat_template
|
||||
self.chat_template_kwargs = chat_template_kwargs
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Apply chat template to the given batch.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to send.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A generator of rows with the chat template applied.
|
||||
"""
|
||||
prompts = []
|
||||
for row in batch:
|
||||
# PyArrow cannot handle the messages with images, so Ray Data
|
||||
# will fallback to use pickle for serialization. In this case,
|
||||
# the "messages" column is already a list of dicts and does not
|
||||
# have .tolist() method.
|
||||
if hasattr(row["messages"], "tolist"):
|
||||
conversation = row["messages"].tolist()
|
||||
else:
|
||||
conversation = row["messages"]
|
||||
add_generation_prompt = self._should_add_generation_prompt(conversation)
|
||||
# If we don't add a generation prompt, we should continue the final message.
|
||||
continue_final_message = not add_generation_prompt
|
||||
prompts.append(
|
||||
self.processor.apply_chat_template(
|
||||
conversation,
|
||||
tokenize=False,
|
||||
chat_template=self.chat_template,
|
||||
add_generation_prompt=add_generation_prompt,
|
||||
continue_final_message=continue_final_message,
|
||||
**(self.chat_template_kwargs or {}),
|
||||
)
|
||||
)
|
||||
assert len(batch) == len(prompts)
|
||||
|
||||
for row, prompt in zip(batch, prompts):
|
||||
yield {
|
||||
self.IDX_IN_BATCH_COLUMN: row[self.IDX_IN_BATCH_COLUMN],
|
||||
"prompt": prompt,
|
||||
}
|
||||
|
||||
def _should_add_generation_prompt(self, conversation: List[Dict[str, Any]]) -> bool:
|
||||
"""Determines if the generation prompt should be added for the given conversation.
|
||||
|
||||
Adds the generation prompt only if the last message is from the user.
|
||||
This is useful in cases where the user provides an assistant prefill
|
||||
message.
|
||||
|
||||
Args:
|
||||
conversation: The conversation to check.
|
||||
|
||||
Returns:
|
||||
True if the generation prompt should be added, False otherwise.
|
||||
"""
|
||||
return conversation[-1]["role"] == "user"
|
||||
|
||||
|
||||
class ChatTemplateStage(StatefulStage):
|
||||
"""
|
||||
A stage that applies chat template.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = ChatTemplateUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {
|
||||
"messages": "A list of messages in OpenAI chat format. "
|
||||
"See https://platform.openai.com/docs/api-reference/chat/create "
|
||||
"for details."
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Shared utilities for stages.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def truncate_str(s: str, max_length: int, suffix: str = "...[truncated]") -> str:
|
||||
"""Truncate a string to max_length, appending suffix if truncated."""
|
||||
if len(s) <= max_length:
|
||||
return s
|
||||
return s[:max_length] + suffix
|
||||
|
||||
|
||||
def maybe_convert_ndarray_to_list(
|
||||
params: Union[np.ndarray, List[Any], Dict[str, Any]]
|
||||
) -> Union[List[Any], Dict[str, Any]]:
|
||||
"""Convert all ndarray to list in the params. This is because Ray Data
|
||||
by default converts all lists to ndarrays when passing data around, but
|
||||
vLLM expects lists.
|
||||
|
||||
Args:
|
||||
params: The parameters to convert.
|
||||
|
||||
Returns:
|
||||
The converted parameters.
|
||||
"""
|
||||
if isinstance(params, dict):
|
||||
return {k: maybe_convert_ndarray_to_list(v) for k, v in params.items()}
|
||||
elif isinstance(params, list):
|
||||
return [maybe_convert_ndarray_to_list(v) for v in params]
|
||||
elif isinstance(params, np.ndarray):
|
||||
return params.tolist()
|
||||
return params
|
||||
@@ -0,0 +1,117 @@
|
||||
from typing import Any, Dict, Literal, Optional, Tuple, Type, TypeVar, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
|
||||
T = TypeVar("T", bound="_StageConfigBase")
|
||||
|
||||
|
||||
class _StageConfigBase(BaseModelExtended):
|
||||
enabled: bool = Field(default=True, description="Whether this stage is enabled.")
|
||||
# Optional overrides; processor-level defaults still apply
|
||||
batch_size: Optional[int] = Field(default=None, description="Rows per batch.")
|
||||
concurrency: Optional[Union[int, Tuple[int, int]]] = Field(
|
||||
default=None, description="Actor pool size or range for this stage."
|
||||
)
|
||||
runtime_env: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="Optional runtime env for this stage."
|
||||
)
|
||||
num_cpus: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Number of CPUs to reserve for each map worker in this stage.",
|
||||
)
|
||||
memory: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Heap memory in bytes to reserve for each map worker in this stage.",
|
||||
)
|
||||
|
||||
|
||||
class ChatTemplateStageConfig(_StageConfigBase):
|
||||
model_source: Optional[str] = Field(
|
||||
default=None, description="Model source/identifier for this stage."
|
||||
)
|
||||
chat_template: Optional[str] = Field(default=None)
|
||||
chat_template_kwargs: Optional[Dict[str, Any]] = Field(default=None)
|
||||
|
||||
|
||||
class TokenizerStageConfig(_StageConfigBase):
|
||||
model_source: Optional[str] = Field(
|
||||
default=None, description="Model source/identifier for this stage."
|
||||
)
|
||||
|
||||
|
||||
class DetokenizeStageConfig(_StageConfigBase):
|
||||
model_source: Optional[str] = Field(
|
||||
default=None, description="Model source/identifier for this stage."
|
||||
)
|
||||
|
||||
|
||||
class PrepareMultimodalStageConfig(_StageConfigBase):
|
||||
model_config_kwargs: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Optional kwargs to pass to the model config. See available model config "
|
||||
"kwargs at https://docs.vllm.ai/en/latest/api/vllm/config/#vllm.config.ModelConfig",
|
||||
)
|
||||
chat_template_content_format: Optional[Literal["string", "openai"]] = Field(
|
||||
default="string",
|
||||
description="The content format to use for the chat template. "
|
||||
"This is used to format the chat template content according to a specific model.",
|
||||
)
|
||||
apply_sys_msg_formatting: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Whether to apply formatting system messages.",
|
||||
)
|
||||
|
||||
|
||||
class HttpRequestStageConfig(_StageConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_stage_config(
|
||||
stage_cfg_value: Union[bool, Dict[str, Any], _StageConfigBase],
|
||||
stage_config_cls: Type[T],
|
||||
processor_defaults: Optional[Dict[str, Any]] = None,
|
||||
) -> T:
|
||||
"""Resolve a stage config value (bool | dict | StageConfig) into a typed StageConfig.
|
||||
|
||||
Args:
|
||||
stage_cfg_value: The stage config value (bool, dict, or typed StageConfig).
|
||||
stage_config_cls: The StageConfig class to instantiate.
|
||||
processor_defaults: Optional dict of processor-level defaults to merge in.
|
||||
Expected keys: 'batch_size', 'concurrency', 'runtime_env', 'model_source'.
|
||||
|
||||
Returns:
|
||||
Resolved StageConfig instance with defaults merged.
|
||||
"""
|
||||
processor_defaults = processor_defaults or {}
|
||||
|
||||
# If already a typed config, create a copy to avoid mutating the input
|
||||
if isinstance(stage_cfg_value, stage_config_cls):
|
||||
resolved = stage_config_cls.model_validate(stage_cfg_value.model_dump())
|
||||
# If bool, create minimal config with enabled flag
|
||||
elif isinstance(stage_cfg_value, bool):
|
||||
resolved = stage_config_cls(enabled=stage_cfg_value)
|
||||
# If dict, parse it into the config class
|
||||
elif isinstance(stage_cfg_value, dict):
|
||||
resolved = stage_config_cls(**stage_cfg_value)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Unsupported type for stage config: {type(stage_cfg_value).__name__}. "
|
||||
f"Expected bool, dict, or {stage_config_cls.__name__} instance. "
|
||||
f"Got: {stage_cfg_value}"
|
||||
)
|
||||
|
||||
# Merge processor defaults for fields not explicitly set
|
||||
default_fields = ["batch_size", "concurrency", "runtime_env", "model_source"]
|
||||
for field_name in default_fields:
|
||||
# Skip if field doesn't exist on this config class (e.g., model_source only on some stages)
|
||||
if not hasattr(resolved, field_name):
|
||||
continue
|
||||
if (
|
||||
getattr(resolved, field_name, None) is None
|
||||
and field_name in processor_defaults
|
||||
):
|
||||
setattr(resolved, field_name, processor_defaults[field_name])
|
||||
|
||||
return resolved
|
||||
@@ -0,0 +1,177 @@
|
||||
"""HTTP Request Stage"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Type
|
||||
|
||||
import aiohttp
|
||||
import aiohttp.web_exceptions
|
||||
import numpy as np
|
||||
from aiohttp.client_exceptions import ClientPayloadError
|
||||
|
||||
from ray.llm._internal.batch.stages.base import StatefulStage, StatefulStageUDF
|
||||
|
||||
|
||||
class NumpyEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
elif isinstance(obj, (np.integer, np.floating)):
|
||||
return obj.item()
|
||||
elif isinstance(obj, np.bool_):
|
||||
return bool(obj)
|
||||
else:
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class HttpRequestUDF(StatefulStageUDF):
|
||||
RETRYABLE_STATUS_CODES = [429, 408, 504, 502, 503]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
url: str,
|
||||
additional_header: Optional[Dict[str, Any]] = None,
|
||||
qps: Optional[int] = None,
|
||||
max_retries: int = 0,
|
||||
base_retry_wait_time_in_s: float = 1.0,
|
||||
session_factory: Optional[Callable[[], aiohttp.ClientSession]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the HttpRequestUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
url: The URL to send the HTTP request to.
|
||||
additional_header: The additional headers to send with the HTTP request.
|
||||
qps: The maximum number of requests per second.
|
||||
max_retries: The maximum number of retries per request in the event of failures. We retry with exponential backoff upto this specific maximum retries.
|
||||
base_retry_wait_time_in_s: The base retry wait time during exponential backoff.
|
||||
session_factory: Optional session factory to be used for initializing a client session.
|
||||
"""
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
self.url = url
|
||||
self.additional_header = additional_header or {}
|
||||
self.qps = qps
|
||||
self.max_retries = max_retries
|
||||
self.base_retry_wait_time_in_s = base_retry_wait_time_in_s
|
||||
self.session_factory = session_factory or aiohttp.ClientSession
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Send HTTP requests to the given URL.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to send.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A generator of rows of the response of the HTTP request.
|
||||
"""
|
||||
# preprocess to get request body for the given batch
|
||||
request_bodies = [None] * len(batch)
|
||||
for row in batch:
|
||||
# Normalize the row to a JSON body.
|
||||
request_bodies[row[self.IDX_IN_BATCH_COLUMN]] = json.dumps(
|
||||
row["payload"], cls=NumpyEncoder
|
||||
)
|
||||
|
||||
async with self.session_factory() as session:
|
||||
start_time = time.time()
|
||||
request_count = 0
|
||||
pending_requests = []
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**self.additional_header,
|
||||
}
|
||||
|
||||
# First send all requests based on QPS
|
||||
for row in batch:
|
||||
# Rate limit based on qps if specified
|
||||
if self.qps is not None:
|
||||
request_count += 1
|
||||
expected_time = request_count / self.qps
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < expected_time:
|
||||
await asyncio.sleep(expected_time - elapsed)
|
||||
|
||||
# self.IDX_IN_BATCH_COLUMN is the index of row in the batch
|
||||
json_body = request_bodies[row[self.IDX_IN_BATCH_COLUMN]]
|
||||
# Create request but don't await it yet
|
||||
request = session.post(
|
||||
self.url,
|
||||
headers=headers,
|
||||
data=json_body,
|
||||
)
|
||||
pending_requests.append((row[self.IDX_IN_BATCH_COLUMN], request))
|
||||
|
||||
# Now receive all responses
|
||||
for idx_in_batch_column, request in pending_requests:
|
||||
resp_json = None
|
||||
last_exception = None
|
||||
last_exception_traceback = None
|
||||
for retry_count in range(self.max_retries + 1):
|
||||
if retry_count > 0:
|
||||
json_body = request_bodies[idx_in_batch_column]
|
||||
request = session.post(
|
||||
self.url,
|
||||
headers=headers,
|
||||
data=json_body,
|
||||
)
|
||||
try:
|
||||
async with await request as response:
|
||||
status_code = response.status
|
||||
# check status and see if it's retry worthy
|
||||
if status_code in self.RETRYABLE_STATUS_CODES:
|
||||
last_exception = aiohttp.web_exceptions.HTTPException(
|
||||
reason=response.reason
|
||||
)
|
||||
last_exception.status_code = status_code
|
||||
wait_time = self.base_retry_wait_time_in_s * (
|
||||
2**retry_count
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
resp_json = await response.json()
|
||||
if self.IDX_IN_BATCH_COLUMN in resp_json:
|
||||
raise ValueError(
|
||||
"The response of the HTTP request must not contain "
|
||||
f"the column {self.IDX_IN_BATCH_COLUMN}."
|
||||
)
|
||||
break
|
||||
except (
|
||||
asyncio.TimeoutError,
|
||||
aiohttp.ClientConnectionError,
|
||||
ClientPayloadError,
|
||||
) as e:
|
||||
last_exception_traceback = traceback.format_exc()
|
||||
last_exception = type(e).__name__
|
||||
wait_time = self.base_retry_wait_time_in_s * (2**retry_count)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
if not resp_json:
|
||||
raise RuntimeError(
|
||||
f"Reached maximum retries of {self.max_retries} for input row {batch[idx_in_batch_column]}. Previous Exception: {last_exception}. Full Traceback: \n{last_exception_traceback}"
|
||||
)
|
||||
yield {
|
||||
self.IDX_IN_BATCH_COLUMN: idx_in_batch_column,
|
||||
"http_response": resp_json,
|
||||
}
|
||||
|
||||
|
||||
class HttpRequestStage(StatefulStage):
|
||||
"""
|
||||
A stage that sends HTTP requests.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = HttpRequestUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {
|
||||
"payload": "The payload to send to the HTTP request. "
|
||||
"It should be in JSON format."
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Prepare Multimodal Stage"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator, Dict, List
|
||||
|
||||
from ray.llm._internal.batch.stages.base import StatefulStage, StatefulStageUDF
|
||||
|
||||
|
||||
class PrepareMultimodalUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
model_config_kwargs: Dict[str, Any],
|
||||
chat_template_content_format: str,
|
||||
apply_sys_msg_formatting: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the PrepareMultimodalUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
model_config_kwargs: The kwargs to pass to the model config.
|
||||
chat_template_content_format: The format to render message content.
|
||||
apply_sys_msg_formatting: Whether to skip formatting system messages.
|
||||
"""
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
|
||||
try:
|
||||
from vllm.config import ModelConfig
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"vLLM is not installed or failed to import. Please run "
|
||||
"`pip install ray[llm]` to install required dependencies."
|
||||
) from e
|
||||
|
||||
self.model_config = ModelConfig(**model_config_kwargs)
|
||||
self.chat_template_content_format = chat_template_content_format
|
||||
self.apply_sys_msg_formatting = apply_sys_msg_formatting
|
||||
|
||||
def _extract_system_messages(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Extract system messages from the message list.
|
||||
|
||||
System messages are kept as strings (not converted to list format) to avoid
|
||||
issues with chat templates that expect string system messages, e.g. Pixtral.
|
||||
|
||||
Args:
|
||||
messages: The full message list.
|
||||
|
||||
Returns:
|
||||
A tuple of (system_messages, non_system_messages).
|
||||
"""
|
||||
system_messages = []
|
||||
non_system_messages = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
system_content = msg.get("content")
|
||||
if isinstance(system_content, list):
|
||||
text_parts = []
|
||||
for part in system_content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_value = part.get("text") or part.get("content")
|
||||
if text_value:
|
||||
text_parts.append(str(text_value))
|
||||
elif isinstance(part, str) and part:
|
||||
text_parts.append(part)
|
||||
system_content = "\n".join(text_parts) if text_parts else ""
|
||||
|
||||
system_messages.append({**msg, "content": system_content})
|
||||
else:
|
||||
non_system_messages.append(msg)
|
||||
|
||||
return system_messages, non_system_messages
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Process multimodal data from input messages.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to process.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A dictionary containing the multimodal data
|
||||
along with processing metadata.
|
||||
"""
|
||||
try:
|
||||
from vllm.entrypoints.chat_utils import parse_chat_messages_async
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"vLLM is not installed or failed to import. Please run "
|
||||
"`pip install ray[llm]` to install required dependencies."
|
||||
) from e
|
||||
|
||||
async def _process_row(row: Dict[str, Any]):
|
||||
# Extract system messages to keep them as strings (not converted to list format)
|
||||
# This avoids issues with chat templates that expect string system messages.
|
||||
system_messages = []
|
||||
messages_to_parse = row["messages"]
|
||||
|
||||
if self.apply_sys_msg_formatting:
|
||||
system_messages, messages_to_parse = self._extract_system_messages(
|
||||
row["messages"]
|
||||
)
|
||||
|
||||
# Users can provide stable IDs for each multimodal item from messages to
|
||||
# enable engine to cache and reuse work across requests.
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages_to_parse,
|
||||
self.model_config,
|
||||
content_format=self.chat_template_content_format,
|
||||
)
|
||||
|
||||
if system_messages:
|
||||
conversation = system_messages + conversation
|
||||
|
||||
return row, conversation, mm_uuids, mm_data
|
||||
|
||||
tasks = [asyncio.create_task(_process_row(row)) for row in batch]
|
||||
|
||||
for task in asyncio.as_completed(tasks):
|
||||
row, conversation, uuid, multimodal_data = await task
|
||||
|
||||
output = {
|
||||
k: v
|
||||
for k, v in row.items()
|
||||
if k not in ("messages", self.IDX_IN_BATCH_COLUMN)
|
||||
}
|
||||
output.update(
|
||||
{
|
||||
self.IDX_IN_BATCH_COLUMN: row[self.IDX_IN_BATCH_COLUMN],
|
||||
"multimodal_data": multimodal_data,
|
||||
# Use the parsed conversation which has placeholders embedded instead of the original messages
|
||||
"messages": conversation,
|
||||
"multimodal_uuids": uuid,
|
||||
}
|
||||
)
|
||||
yield output
|
||||
|
||||
|
||||
class PrepareMultimodalStage(StatefulStage):
|
||||
"""
|
||||
A stage that prepares multimodal data from the input messages for a specific model.
|
||||
"""
|
||||
|
||||
fn: StatefulStageUDF = PrepareMultimodalUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {
|
||||
"messages": "A list of messages in OpenAI chat format. "
|
||||
"See https://platform.openai.com/docs/api-reference/chat/create "
|
||||
"for details."
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"""The stage that runs serve deployment."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Type
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ray import serve
|
||||
from ray.exceptions import RayTaskError
|
||||
from ray.llm._internal.batch.stages.base import (
|
||||
StatefulStage,
|
||||
StatefulStageUDF,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.common import truncate_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROMPT_LENGTH_IN_ERROR = 200
|
||||
|
||||
# Request-level errors safe to catch. Unknown errors are treated as fatal.
|
||||
# TimeoutError is included so a single stuck request (see request_timeout_s)
|
||||
# is dropped as an error row rather than blocking the batch forever.
|
||||
_SERVE_RECOVERABLE_ERRORS = (
|
||||
ValueError,
|
||||
TypeError,
|
||||
ValidationError,
|
||||
TimeoutError,
|
||||
)
|
||||
|
||||
|
||||
class ServeDeploymentStageUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
*,
|
||||
deployment_name: str,
|
||||
app_name: str,
|
||||
dtype_mapping: Dict[str, Type[Any]],
|
||||
should_continue_on_error: bool = False,
|
||||
request_timeout_s: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the ServeDeploymentStageUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
deployment_name: The name of the deployment.
|
||||
app_name: The name of the deployment app.
|
||||
dtype_mapping: The mapping of the request class name to the request class.
|
||||
should_continue_on_error: If True, continue processing when inference
|
||||
fails for a row instead of raising. Failed rows will have
|
||||
'__inference_error__' set to the error message.
|
||||
request_timeout_s: Optional per-request timeout in seconds. When set,
|
||||
a request that does not return within this many seconds raises
|
||||
TimeoutError instead of awaiting indefinitely. TimeoutError is
|
||||
recoverable, so with should_continue_on_error=True the row is
|
||||
emitted as an error row rather than crashing the batch.
|
||||
"""
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
self._dtype_mapping = dtype_mapping
|
||||
self.should_continue_on_error = should_continue_on_error
|
||||
self.request_timeout_s = request_timeout_s
|
||||
|
||||
# Using stream=True as LLM serve deployments return async generators.
|
||||
# TODO (Kourosh): Generalize this to support non-streaming deployments.
|
||||
self._dh = serve.get_deployment_handle(deployment_name, app_name).options(
|
||||
stream=True
|
||||
)
|
||||
self.request_id = 0
|
||||
|
||||
def _prepare_request(
|
||||
self, row: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], Optional[Type[Any]], str]:
|
||||
"""
|
||||
Decorate the request with metadata related to the batch.
|
||||
|
||||
Args:
|
||||
row: The row.
|
||||
|
||||
Returns:
|
||||
A tuple of (decorated_request, dtype, method_name). dtype is the class of
|
||||
the request object and can be None if the serve deployment accepts a raw
|
||||
dict. method_name is the name of the method to invoke on the deployment.
|
||||
"""
|
||||
method = row.get("method")
|
||||
dtype_name = row.get("dtype")
|
||||
|
||||
dtype = None
|
||||
if dtype_name is not None:
|
||||
if not self._dtype_mapping or dtype_name not in self._dtype_mapping:
|
||||
raise ValueError(
|
||||
f"{dtype_name} must be provided in "
|
||||
"ServeDeploymentProcessorConfig's dtype_mapping."
|
||||
)
|
||||
dtype = self._dtype_mapping[dtype_name]
|
||||
|
||||
request_kwargs = row.pop("request_kwargs")
|
||||
request = {
|
||||
"request_id": str(self.request_id),
|
||||
"idx_in_batch": row[self.IDX_IN_BATCH_COLUMN],
|
||||
**request_kwargs,
|
||||
}
|
||||
self.request_id += 1
|
||||
|
||||
return request, dtype, method
|
||||
|
||||
async def generate_async(
|
||||
self, row: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any], float]:
|
||||
"""
|
||||
Run the serve deployment.
|
||||
|
||||
Args:
|
||||
row: The row to run the serve deployment on.
|
||||
|
||||
Returns:
|
||||
The response from the serve deployment.
|
||||
"""
|
||||
request, dtype, method = self._prepare_request(row)
|
||||
request_obj = dtype(**request) if dtype else request
|
||||
|
||||
if getattr(self._dh, method) is None:
|
||||
raise ValueError(f"Method {method} not found in the serve deployment.")
|
||||
|
||||
t = time.perf_counter()
|
||||
# Directly using anext() requires python3.10 and above
|
||||
response_gen = getattr(self._dh, method).remote(request_obj)
|
||||
if self.request_timeout_s is not None:
|
||||
try:
|
||||
output_data = await asyncio.wait_for(
|
||||
response_gen.__anext__(), timeout=self.request_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError as e:
|
||||
raise TimeoutError(
|
||||
f"Request timed out after {self.request_timeout_s}s waiting "
|
||||
f"for deployment '{method}' to return a response."
|
||||
) from e
|
||||
else:
|
||||
output_data = await response_gen.__anext__()
|
||||
time_taken = time.perf_counter() - t
|
||||
|
||||
# Convert the output data to a dict if it is a Pydantic model.
|
||||
if isinstance(output_data, BaseModel):
|
||||
output_data = output_data.model_dump()
|
||||
|
||||
return request, output_data, time_taken
|
||||
|
||||
def _is_recoverable_error(self, exc: Exception) -> bool:
|
||||
"""Check if exception is recoverable. Unknown errors are treated as fatal."""
|
||||
if isinstance(exc, _SERVE_RECOVERABLE_ERRORS):
|
||||
return True
|
||||
# RayTaskError wraps remote exceptions - check the cause
|
||||
if isinstance(exc, RayTaskError) and hasattr(exc, "cause"):
|
||||
if isinstance(exc.cause, _SERVE_RECOVERABLE_ERRORS):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _generate_with_error_handling(
|
||||
self,
|
||||
row: Dict[str, Any],
|
||||
batch_uuid: uuid.UUID,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate output for a row, yielding error row on recoverable failure."""
|
||||
idx_in_batch = row[self.IDX_IN_BATCH_COLUMN]
|
||||
# Save before generate_async pops it
|
||||
original_request_kwargs = row.get("request_kwargs", {})
|
||||
try:
|
||||
request, output, time_taken = await self.generate_async(row)
|
||||
|
||||
return {
|
||||
**output,
|
||||
"request_id": request["request_id"],
|
||||
self.IDX_IN_BATCH_COLUMN: request["idx_in_batch"],
|
||||
"batch_uuid": batch_uuid.hex,
|
||||
"time_taken": time_taken,
|
||||
"__inference_error__": "",
|
||||
}
|
||||
except Exception as e:
|
||||
# Only recover from known recoverable errors; unknown errors propagate
|
||||
if not self._is_recoverable_error(e) or not self.should_continue_on_error:
|
||||
raise
|
||||
|
||||
error_msg = f"{type(e).__name__}: {str(e)}"
|
||||
logger.warning(
|
||||
"[Serve Deployment] Inference failed for row %d in batch %s: %s",
|
||||
idx_in_batch,
|
||||
batch_uuid.hex,
|
||||
error_msg,
|
||||
)
|
||||
|
||||
# Include request_kwargs snippet for debuggability
|
||||
request_str = truncate_str(
|
||||
str(original_request_kwargs), _MAX_PROMPT_LENGTH_IN_ERROR
|
||||
)
|
||||
|
||||
return {
|
||||
self.IDX_IN_BATCH_COLUMN: idx_in_batch,
|
||||
"batch_uuid": batch_uuid.hex,
|
||||
"__inference_error__": error_msg,
|
||||
"request_kwargs": request_str,
|
||||
}
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Run the serve deployment.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to run the serve deployment on.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A dictionary containing the response from the serve
|
||||
deployment along with processing metadata.
|
||||
"""
|
||||
batch_uuid = uuid.uuid4()
|
||||
t = time.perf_counter()
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(self._generate_with_error_handling(row, batch_uuid))
|
||||
for row in batch
|
||||
]
|
||||
|
||||
for resp in asyncio.as_completed(tasks):
|
||||
yield await resp
|
||||
|
||||
batch_time_taken = time.perf_counter() - t
|
||||
logger.info(
|
||||
"[LLM Batch - Serve Deployment] Elapsed time for batch %s with size %d: %s",
|
||||
batch_uuid.hex,
|
||||
len(batch),
|
||||
batch_time_taken,
|
||||
)
|
||||
|
||||
|
||||
class ServeDeploymentStage(StatefulStage):
|
||||
fn: Type[StatefulStageUDF] = ServeDeploymentStageUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
return {
|
||||
"method": "Name of the method to invoke on the serve deployment.",
|
||||
"request_kwargs": "The request_kwargs to construct the request.",
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""The stage that runs SGLang engine."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Type
|
||||
|
||||
from pydantic import BaseModel, root_validator
|
||||
|
||||
from ray.llm._internal.batch.constants import SGLangTaskType, TypeSGLangTaskType
|
||||
from ray.llm._internal.batch.stages.base import (
|
||||
StatefulStage,
|
||||
StatefulStageUDF,
|
||||
)
|
||||
from ray.llm._internal.batch.stages.common import maybe_convert_ndarray_to_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SGLangEngineRequest(BaseModel):
|
||||
"""A request to the SGLang engine."""
|
||||
|
||||
# The request ID for the LLM engine (unique per replica).
|
||||
request_id: int
|
||||
# The index of the request in the batch.
|
||||
idx_in_batch: int
|
||||
# The input prompt.
|
||||
prompt: Optional[str] = None
|
||||
# Alternative to text. Specify the input as token IDs instead of text.
|
||||
prompt_token_ids: Optional[List[int]] = None
|
||||
# The sampling parameters (more details can be seen in https://docs.sglang.ai/backend/sampling_params.html).
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
validate_assignment = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class SGLangOutputData(BaseModel):
|
||||
"""The output of the SGLang engine."""
|
||||
|
||||
prompt: Optional[str] = None
|
||||
prompt_token_ids: Optional[List[int]] = None
|
||||
num_input_tokens: int
|
||||
|
||||
# Generate fields.
|
||||
generated_tokens: Optional[List[int]] = None
|
||||
generated_text: Optional[str] = None
|
||||
num_generated_tokens: int
|
||||
|
||||
# Metrics fields.
|
||||
metrics: Optional[Dict[str, Any]] = None
|
||||
|
||||
@classmethod
|
||||
def from_sglang_engine_output(cls, output: Dict[str, Any]) -> "SGLangOutputData":
|
||||
"""Create a SGLangOutputData from a SGLang engine output."""
|
||||
|
||||
# Set by `_generate_async`.
|
||||
assert "prompt" in output
|
||||
assert "prompt_token_ids" in output
|
||||
|
||||
# Returned in the native output of the SGLang engine.
|
||||
assert "meta_info" in output
|
||||
assert "prompt_tokens" in output["meta_info"]
|
||||
assert "completion_tokens" in output["meta_info"]
|
||||
|
||||
data = cls(
|
||||
prompt=output["prompt"],
|
||||
prompt_token_ids=output["prompt_token_ids"],
|
||||
num_input_tokens=output["meta_info"]["prompt_tokens"],
|
||||
generated_tokens=output["output_ids"] if "output_ids" in output else None,
|
||||
generated_text=output["text"] if "text" in output else None,
|
||||
num_generated_tokens=output["meta_info"]["completion_tokens"],
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
class Config:
|
||||
validate_assignment = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class SGLangEngineWrapper:
|
||||
"""Wrapper around the SGLang engine to handle async requests.
|
||||
|
||||
Args:
|
||||
idx_in_batch_column: The column name for the index of the row in the batch.
|
||||
max_pending_requests: The maximum number of pending requests in the queue.
|
||||
**kwargs: The keyword arguments for the engine.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
idx_in_batch_column: str,
|
||||
max_pending_requests: int = -1,
|
||||
**kwargs,
|
||||
):
|
||||
self.request_id = 0
|
||||
self.idx_in_batch_column = idx_in_batch_column
|
||||
self.task_type = kwargs.pop("task", SGLangTaskType.GENERATE)
|
||||
self.model = kwargs.pop("model", None)
|
||||
assert self.model is not None
|
||||
# We need to rename the `model` to `model_path` for SGLang.
|
||||
kwargs["model_path"] = self.model
|
||||
|
||||
# Set the skip_tokenizer_init to True by default for SGLang engine
|
||||
# because we will not use the tokenizer/detokenizer in SGLang engine
|
||||
# by default.
|
||||
self.skip_tokenizer_init = kwargs.pop("skip_tokenizer_init", True)
|
||||
kwargs["skip_tokenizer_init"] = self.skip_tokenizer_init
|
||||
|
||||
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
|
||||
|
||||
# Initialize the SGLang engine
|
||||
self.engine = sglang.Engine(**kwargs)
|
||||
|
||||
# The performance gets really bad if there are too many requests in the pending queue.
|
||||
# We work around it with semaphore to limit the number of concurrent requests in the engine.
|
||||
self.max_pending_requests = max_pending_requests
|
||||
if self.max_pending_requests > 0:
|
||||
self.semaphore = asyncio.Semaphore(self.max_pending_requests)
|
||||
else:
|
||||
# Use contextlib.nullcontext which works for both sync and async contexts.
|
||||
self.semaphore = nullcontext()
|
||||
|
||||
async def _prepare_llm_request(self, row: Dict[str, Any]) -> SGLangEngineRequest:
|
||||
"""Prepare the inputs for LLM inference.
|
||||
|
||||
Args:
|
||||
row: The row.
|
||||
|
||||
Returns:
|
||||
A single SGLangEngineRequest.
|
||||
"""
|
||||
prompt = row.pop("prompt")
|
||||
|
||||
if "tokenized_prompt" in row:
|
||||
tokenized_prompt = row.pop("tokenized_prompt").tolist()
|
||||
else:
|
||||
tokenized_prompt = None
|
||||
|
||||
# Prepare sampling parameters.
|
||||
if self.task_type == SGLangTaskType.GENERATE:
|
||||
params = maybe_convert_ndarray_to_list(row.pop("sampling_params"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported task type: {self.task_type}")
|
||||
|
||||
if tokenized_prompt is not None and not self.skip_tokenizer_init:
|
||||
raise ValueError(
|
||||
"To use a token-in-token-out mode of SGLang Engine, please set engine_kwargs['skip_tokenizer_init'] to True."
|
||||
)
|
||||
|
||||
request = SGLangEngineRequest(
|
||||
request_id=self.request_id,
|
||||
idx_in_batch=row[self.idx_in_batch_column],
|
||||
prompt=prompt,
|
||||
prompt_token_ids=tokenized_prompt,
|
||||
params=params,
|
||||
)
|
||||
self.request_id += 1
|
||||
return request
|
||||
|
||||
async def generate_async(
|
||||
self, row: Dict[str, Any]
|
||||
) -> Tuple[SGLangEngineRequest, Dict[str, Any], float]:
|
||||
"""Process a single request.
|
||||
|
||||
Args:
|
||||
row: The input row.
|
||||
|
||||
Returns:
|
||||
A tuple of index in batch, request output and bypassed custom fields, and time taken.
|
||||
"""
|
||||
request = await self._prepare_llm_request(row)
|
||||
t = time.perf_counter()
|
||||
|
||||
async with self.semaphore:
|
||||
output = await self._generate_async(request)
|
||||
|
||||
time_taken = time.perf_counter() - t
|
||||
|
||||
output_data = SGLangOutputData.from_sglang_engine_output(output)
|
||||
return request, output_data.model_dump(), time_taken
|
||||
|
||||
async def _generate_async(self, request: SGLangEngineRequest) -> Any:
|
||||
"""Process a single request.
|
||||
|
||||
Args:
|
||||
request: The request.
|
||||
|
||||
Returns:
|
||||
The output of the request.
|
||||
"""
|
||||
|
||||
# Send the request to the LLM engine.
|
||||
stream = await self.engine.async_generate(
|
||||
prompt=request.prompt,
|
||||
input_ids=request.prompt_token_ids,
|
||||
sampling_params=request.params,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Consume the stream until the request is finished.
|
||||
async for output in stream:
|
||||
if output["meta_info"]["finish_reason"] is not None:
|
||||
output["prompt"] = request.prompt
|
||||
output["prompt_token_ids"] = request.prompt_token_ids
|
||||
return output
|
||||
|
||||
raise RuntimeError(
|
||||
"[SGLang] The request is not finished. This should not happen. Please report this issue to the Ray team."
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the SGLang engine."""
|
||||
if hasattr(self.engine, "shutdown"):
|
||||
logger.info("Shutting down SGLang engine")
|
||||
self.engine.shutdown()
|
||||
|
||||
|
||||
class SGLangEngineStageUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
model: str,
|
||||
engine_kwargs: Dict[str, Any],
|
||||
task_type: TypeSGLangTaskType = SGLangTaskType.GENERATE,
|
||||
max_pending_requests: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the SGLangEngineStageUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
model: The path to the model to use for the SGLang engine.
|
||||
engine_kwargs: The kwargs to pass to the SGLang engine.
|
||||
task_type: The task to use for the SGLang engine (e.g., "generate", "embed", "reward").
|
||||
max_pending_requests: The maximum number of pending requests. If None,
|
||||
it will be set to a default value based on engine settings.
|
||||
"""
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
self.model = model
|
||||
|
||||
# Setup SGLang engine kwargs.
|
||||
self.task_type = task_type
|
||||
self.engine_kwargs = self.normalize_engine_kwargs(engine_kwargs)
|
||||
|
||||
# Set up the max pending requests.
|
||||
# Disable the semaphore if max_pending_requests is not set.
|
||||
self.max_pending_requests = max_pending_requests or -1
|
||||
if self.max_pending_requests > 0:
|
||||
logger.info("Max pending requests is set to %d", self.max_pending_requests)
|
||||
|
||||
# Create an LLM engine.
|
||||
self.llm = SGLangEngineWrapper(
|
||||
model=self.model,
|
||||
idx_in_batch_column=self.IDX_IN_BATCH_COLUMN,
|
||||
max_pending_requests=self.max_pending_requests,
|
||||
**self.engine_kwargs,
|
||||
)
|
||||
|
||||
def normalize_engine_kwargs(
|
||||
self,
|
||||
engine_kwargs: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize the engine kwargs.
|
||||
|
||||
Args:
|
||||
engine_kwargs: The kwargs to normalize.
|
||||
|
||||
Returns:
|
||||
The normalized kwargs.
|
||||
"""
|
||||
# Copy to avoid mutating fn_constructor_kwargs. Ray Data generates UDF
|
||||
# instance keys before __init__, so in-place changes cause KeyError.
|
||||
engine_kwargs = engine_kwargs.copy()
|
||||
|
||||
# Remove model from engine kwargs if set.
|
||||
model = engine_kwargs.pop("model", None)
|
||||
if model is not None and model != self.model:
|
||||
logger.warning(
|
||||
"The model set in engine kwargs (%s) is different from the "
|
||||
"stage (%s). Please remove 'model' from engine kwargs.",
|
||||
model,
|
||||
self.model,
|
||||
)
|
||||
return engine_kwargs
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Run the SGLang engine.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to run the SGLang engine on.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: The response of the SGLang engine.
|
||||
"""
|
||||
batch_uuid = uuid.uuid4()
|
||||
batch_start_time = time.perf_counter()
|
||||
|
||||
tasks = [asyncio.create_task(self.llm.generate_async(row)) for row in batch]
|
||||
|
||||
for resp in asyncio.as_completed(tasks):
|
||||
request, output, time_taken_llm = await resp
|
||||
|
||||
yield {
|
||||
**output,
|
||||
"request_id": request.request_id,
|
||||
self.IDX_IN_BATCH_COLUMN: request.idx_in_batch,
|
||||
"batch_uuid": batch_uuid.hex,
|
||||
"time_taken_llm": time_taken_llm,
|
||||
"params": str(request.params),
|
||||
}
|
||||
|
||||
batch_time_taken = time.perf_counter() - batch_start_time
|
||||
logger.info(
|
||||
"[SGLang] Elapsed time for batch %s with size %d: %s",
|
||||
batch_uuid.hex,
|
||||
len(batch),
|
||||
batch_time_taken,
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
if hasattr(self, "llm"):
|
||||
self.llm.shutdown()
|
||||
|
||||
|
||||
class SGLangEngineStage(StatefulStage):
|
||||
"""
|
||||
A stage that runs SGLang engine.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = SGLangEngineStageUDF
|
||||
|
||||
@root_validator(pre=True)
|
||||
def post_init(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Post-initialize the stage. Specifically,
|
||||
this function determines the num_gpus and Ray remote args
|
||||
for the .map_batches() call in this stage.
|
||||
|
||||
Args:
|
||||
values: The raw stage values.
|
||||
Returns:
|
||||
The updated values.
|
||||
"""
|
||||
map_batches_kwargs = values["map_batches_kwargs"]
|
||||
accelerator_type = map_batches_kwargs.get("accelerator_type", "")
|
||||
fn_constructor_kwargs = values["fn_constructor_kwargs"]
|
||||
engine_kwargs = fn_constructor_kwargs.get("engine_kwargs", {})
|
||||
|
||||
ray_remote_args = {}
|
||||
if accelerator_type:
|
||||
ray_remote_args["accelerator_type"] = accelerator_type
|
||||
|
||||
# Set up num_gpus required
|
||||
tp_size = engine_kwargs.get("tp_size", 1)
|
||||
dp_size = engine_kwargs.get("dp_size", 1)
|
||||
num_gpus = tp_size * dp_size
|
||||
|
||||
ray_remote_args["num_gpus"] = num_gpus
|
||||
map_batches_kwargs.update(ray_remote_args)
|
||||
return values
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
ret = {"prompt": "The text prompt (str)."}
|
||||
task_type = self.fn_constructor_kwargs.get("task_type", SGLangTaskType.GENERATE)
|
||||
if task_type == SGLangTaskType.GENERATE:
|
||||
ret[
|
||||
"sampling_params"
|
||||
] = "The sampling parameters. See https://docs.sglang.ai/backend/sampling_params.html for details."
|
||||
return ret
|
||||
|
||||
def get_optional_input_keys(self) -> Dict[str, str]:
|
||||
"""The optional input keys of the stage and their descriptions."""
|
||||
return {}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tokenize and detokenize stage"""
|
||||
|
||||
from typing import Any, AsyncIterator, Dict, List, Type
|
||||
|
||||
from ray.llm._internal.batch.stages.base import (
|
||||
StatefulStage,
|
||||
StatefulStageUDF,
|
||||
)
|
||||
from ray.llm._internal.batch.utils import get_cached_tokenizer
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
NodeModelDownloadable,
|
||||
download_model_files,
|
||||
)
|
||||
|
||||
|
||||
class TokenizeUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
model: str,
|
||||
trust_remote_code: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the TokenizeUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
model: The model to use for tokenization.
|
||||
trust_remote_code: Whether to trust remote code when loading the model.
|
||||
"""
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
# Use EXCLUDE_SAFETENSORS for trust_remote_code models to ensure
|
||||
# Python config files are downloaded.
|
||||
download_mode = (
|
||||
NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
if trust_remote_code
|
||||
else NodeModelDownloadable.TOKENIZER_ONLY
|
||||
)
|
||||
model_path = download_model_files(
|
||||
model_id=model,
|
||||
mirror_config=None,
|
||||
download_model=download_mode,
|
||||
download_extra_files=False,
|
||||
)
|
||||
self.tokenizer = get_cached_tokenizer(
|
||||
AutoTokenizer.from_pretrained(
|
||||
model_path,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
)
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Tokenize the given batch.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to send.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A generator of rows with the tokenized prompt.
|
||||
"""
|
||||
for row, prompt_token_ids in zip(
|
||||
batch,
|
||||
self.tokenizer([row["prompt"] for row in batch])["input_ids"],
|
||||
):
|
||||
yield {
|
||||
self.IDX_IN_BATCH_COLUMN: row[self.IDX_IN_BATCH_COLUMN],
|
||||
"tokenized_prompt": prompt_token_ids,
|
||||
}
|
||||
|
||||
|
||||
class TokenizeStage(StatefulStage):
|
||||
"""
|
||||
A stage that tokenizes the input.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = TokenizeUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {"prompt": "The text prompt (str) to tokenize."}
|
||||
|
||||
|
||||
class DetokenizeUDF(StatefulStageUDF):
|
||||
def __init__(
|
||||
self,
|
||||
data_column: str,
|
||||
expected_input_keys: List[str],
|
||||
model: str,
|
||||
trust_remote_code: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the DetokenizeUDF.
|
||||
|
||||
Args:
|
||||
data_column: The data column name.
|
||||
expected_input_keys: The expected input keys of the stage.
|
||||
model: The model to use for detokenization.
|
||||
trust_remote_code: Whether to trust remote code when loading the model.
|
||||
"""
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
super().__init__(data_column, expected_input_keys)
|
||||
# Use EXCLUDE_SAFETENSORS for trust_remote_code models to ensure
|
||||
# Python config files are downloaded.
|
||||
download_mode = (
|
||||
NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
if trust_remote_code
|
||||
else NodeModelDownloadable.TOKENIZER_ONLY
|
||||
)
|
||||
model_path = download_model_files(
|
||||
model_id=model,
|
||||
mirror_config=None,
|
||||
download_model=download_mode,
|
||||
download_extra_files=False,
|
||||
)
|
||||
self.tokenizer = get_cached_tokenizer(
|
||||
AutoTokenizer.from_pretrained(
|
||||
model_path,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
)
|
||||
|
||||
async def udf(self, batch: List[Dict[str, Any]]) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Detokenize the given batch.
|
||||
|
||||
Args:
|
||||
batch: A list of rows to send.
|
||||
|
||||
Yields:
|
||||
Dict[str, Any]: A generator of rows with the detokenized generated text.
|
||||
"""
|
||||
for row, generated_text in zip(
|
||||
batch,
|
||||
self.tokenizer.batch_decode(
|
||||
[row["generated_tokens"] for row in batch],
|
||||
skip_special_tokens=True,
|
||||
),
|
||||
):
|
||||
yield {
|
||||
self.IDX_IN_BATCH_COLUMN: row[self.IDX_IN_BATCH_COLUMN],
|
||||
"generated_text": generated_text,
|
||||
}
|
||||
|
||||
|
||||
class DetokenizeStage(StatefulStage):
|
||||
"""
|
||||
A stage that detokenizes the input.
|
||||
"""
|
||||
|
||||
fn: Type[StatefulStageUDF] = DetokenizeUDF
|
||||
|
||||
def get_required_input_keys(self) -> Dict[str, str]:
|
||||
"""The required input keys of the stage and their descriptions."""
|
||||
return {"generated_tokens": "A list of generated tokens (int) to detokenize."}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
"""Utility functions for batch processing."""
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from transformers import PreTrainedTokenizer, PreTrainedTokenizerFast
|
||||
|
||||
AnyTokenizer = Union["PreTrainedTokenizer", "PreTrainedTokenizerFast", Any]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cached_tokenizer(tokenizer: AnyTokenizer) -> AnyTokenizer:
|
||||
"""Get tokenizer with cached properties.
|
||||
This will patch the tokenizer object in place.
|
||||
By default, transformers will recompute multiple tokenizer properties
|
||||
each time they are called, leading to a significant slowdown. This
|
||||
function caches these properties for faster access.
|
||||
Args:
|
||||
tokenizer: The tokenizer object.
|
||||
Returns:
|
||||
The patched tokenizer object.
|
||||
"""
|
||||
chat_template = getattr(tokenizer, "chat_template", None)
|
||||
# For VLM, the text tokenizer is wrapped by a processor.
|
||||
if hasattr(tokenizer, "tokenizer"):
|
||||
tokenizer = tokenizer.tokenizer
|
||||
# Some VLM's tokenizer has chat_template attribute (e.g. Qwen/Qwen2-VL-7B-Instruct),
|
||||
# however some other VLM's tokenizer does not have chat_template attribute (e.g.
|
||||
# mistral-community/pixtral-12b). Therefore, we cache the processor's chat_template.
|
||||
if chat_template is None:
|
||||
chat_template = getattr(tokenizer, "chat_template", None)
|
||||
|
||||
tokenizer_all_special_ids = set(tokenizer.all_special_ids)
|
||||
tokenizer_all_special_tokens = set(tokenizer.all_special_tokens)
|
||||
# all_special_tokens_extended is removed in transformers v5, used in latest
|
||||
# SGLang version. We require this SGLang version bc it's ABI compatible with
|
||||
# PyTorch 2.9, which is installed by vLLM.
|
||||
# TODO(seiji) remove the attribute completely once vLLM moves to transformers v5.
|
||||
tokenizer_all_special_tokens_extended = getattr(
|
||||
tokenizer, "all_special_tokens_extended", None
|
||||
)
|
||||
tokenizer_len = len(tokenizer)
|
||||
|
||||
class CachedTokenizer(tokenizer.__class__): # type: ignore
|
||||
@property
|
||||
def all_special_ids(self):
|
||||
return tokenizer_all_special_ids
|
||||
|
||||
@property
|
||||
def all_special_tokens(self):
|
||||
return tokenizer_all_special_tokens
|
||||
|
||||
@property
|
||||
def all_special_tokens_extended(self):
|
||||
return tokenizer_all_special_tokens_extended
|
||||
|
||||
@property
|
||||
def chat_template(self):
|
||||
return chat_template
|
||||
|
||||
def __len__(self):
|
||||
return tokenizer_len
|
||||
|
||||
CachedTokenizer.__name__ = f"Cached{tokenizer.__class__.__name__}"
|
||||
|
||||
tokenizer.__class__ = CachedTokenizer
|
||||
return tokenizer
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import Type, TypeVar
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseModelExtended(BaseModel):
|
||||
# NOTE(edoakes): Pydantic protects the namespace `model_` by default and prints
|
||||
# warnings if you define fields with that prefix. However, we added such fields
|
||||
# before this behavior existed. To avoid spamming user-facing logs, we mark the
|
||||
# namespace as not protected. This means we need to be careful about overriding
|
||||
# internal attributes starting with `model_`.
|
||||
# See: https://github.com/anyscale/ray-llm/issues/1425
|
||||
model_config = ConfigDict(
|
||||
protected_namespaces=tuple(),
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse_yaml(cls: Type[ModelT], file, **kwargs) -> ModelT:
|
||||
kwargs.setdefault("Loader", yaml.SafeLoader)
|
||||
dict_args = yaml.load(file, **kwargs)
|
||||
return cls.model_validate(dict_args)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls: Type[ModelT], path: str, **kwargs) -> ModelT:
|
||||
"""Load a model from a YAML file path."""
|
||||
with open(path, "r") as f:
|
||||
return cls.parse_yaml(f, **kwargs)
|
||||
@@ -0,0 +1,144 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.llm._internal.common.utils.download_utils import NodeModelDownloadable
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallbackCtx:
|
||||
"""
|
||||
Context object passed to all callback hooks.
|
||||
Callbacks can read and modify fields as needed.
|
||||
"""
|
||||
|
||||
worker_node_download_model: Optional["NodeModelDownloadable"] = None
|
||||
"""Model download configuration for worker nodes. Used to specify how
|
||||
models should be downloaded and cached on worker nodes in distributed
|
||||
deployments."""
|
||||
placement_group: Optional[Any] = None
|
||||
"""Ray placement group for resource allocation and scheduling. Controls
|
||||
where and how resources are allocated across the cluster."""
|
||||
runtime_env: Optional[Dict[str, Any]] = None
|
||||
"""Runtime environment configuration for the Ray workers. Includes
|
||||
dependencies, environment variables, and other runtime settings."""
|
||||
custom_data: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Flexible dictionary for callback-specific state and data. Allows
|
||||
callbacks to store and share custom information during initialization."""
|
||||
run_init_node: bool = True
|
||||
"""Whether to run model downloads during initialization. Set to False
|
||||
to skip downloading models."""
|
||||
|
||||
|
||||
class CallbackBase:
|
||||
"""Base class for custom initialization implementations.
|
||||
|
||||
This class defines the interface for custom initialization logic
|
||||
for LLMEngine to be called in node_initialization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_config: "LLMConfig",
|
||||
raise_error_on_callback: bool = True,
|
||||
ctx_kwargs: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.raise_error_on_callback = raise_error_on_callback
|
||||
self.kwargs = kwargs
|
||||
self.llm_config = llm_config
|
||||
|
||||
# Create and store CallbackCtx internally using ctx_kwargs
|
||||
ctx_kwargs = ctx_kwargs or {}
|
||||
self.ctx = CallbackCtx(**ctx_kwargs)
|
||||
|
||||
async def on_before_node_init(self) -> None:
|
||||
"""Called before node initialization begins."""
|
||||
pass
|
||||
|
||||
async def on_after_node_init(self) -> None:
|
||||
"""Called after node initialization completes."""
|
||||
pass
|
||||
|
||||
def on_before_download_model_files_distributed(self) -> None:
|
||||
"""Called before model files are downloaded on each node."""
|
||||
pass
|
||||
|
||||
def _get_method(self, method_name: str) -> Tuple[Callable, bool]:
|
||||
"""Get a callback method."""
|
||||
if not hasattr(self, method_name):
|
||||
raise AttributeError(
|
||||
f"Callback {type(self).__name__} does not have method '{method_name}'"
|
||||
)
|
||||
return getattr(self, method_name), inspect.iscoroutinefunction(
|
||||
getattr(self, method_name)
|
||||
)
|
||||
|
||||
def _handle_callback_error(self, method_name: str, e: Exception) -> None:
|
||||
if self.raise_error_on_callback:
|
||||
raise Exception(
|
||||
f"Error running callback method '{method_name}' on {type(self).__name__}: {str(e)}"
|
||||
) from e
|
||||
else:
|
||||
logger.error(
|
||||
f"Error running callback method '{method_name}' on {type(self).__name__}: {str(e)}"
|
||||
)
|
||||
|
||||
async def run_callback(self, method_name: str) -> None:
|
||||
"""Run a callback method either synchronously or asynchronously.
|
||||
|
||||
Args:
|
||||
method_name: The name of the method to call on the callback
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
method, is_async = self._get_method(method_name)
|
||||
|
||||
try:
|
||||
if is_async:
|
||||
await method()
|
||||
else:
|
||||
method()
|
||||
except Exception as e:
|
||||
self._handle_callback_error(method_name, e)
|
||||
|
||||
def run_callback_sync(self, method_name: str) -> None:
|
||||
"""Run a callback method synchronously
|
||||
|
||||
Args:
|
||||
method_name: The name of the method to call on the callback
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
method, is_async = self._get_method(method_name)
|
||||
|
||||
try:
|
||||
if is_async:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.run_until_complete(method())
|
||||
except RuntimeError:
|
||||
asyncio.run(method())
|
||||
else:
|
||||
method()
|
||||
except Exception as e:
|
||||
self._handle_callback_error(method_name, e)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallbackConfig:
|
||||
"""Configuration for the callback to be used in LLMConfig"""
|
||||
|
||||
callback_class: Union[str, Type[CallbackBase]] = CallbackBase
|
||||
"""Class to use for the callback. Can be custom user defined class"""
|
||||
callback_kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Keyword arguments to pass to the Callback class at construction."""
|
||||
raise_error_on_callback: bool = True
|
||||
"""Whether to raise an error if a callback method fails."""
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from .base import CallbackBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudDownloaderConfig(BaseModel):
|
||||
"""Model for validating CloudDownloader configuration."""
|
||||
|
||||
paths: List[Tuple[str, str]]
|
||||
|
||||
@field_validator("paths")
|
||||
@classmethod
|
||||
def validate_paths(cls, v: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
# Supported cloud storage URI schemes
|
||||
valid_schemes = ("s3://", "gs://", "abfss://", "azure://")
|
||||
|
||||
for i, (cloud_uri, _) in enumerate(v):
|
||||
if not any(cloud_uri.startswith(scheme) for scheme in valid_schemes):
|
||||
raise ValueError(
|
||||
f"paths[{i}][0] (cloud_uri) must start with one of {valid_schemes}, "
|
||||
f"got '{cloud_uri}'"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class CloudDownloader(CallbackBase):
|
||||
"""Callback that downloads files from cloud storage before model files are downloaded.
|
||||
|
||||
This callback expects self.kwargs to contain a 'paths' field which should be
|
||||
a list of tuples, where each tuple contains (cloud_uri, local_path) strings.
|
||||
|
||||
Supported cloud storage URIs: s3://, gs://, abfss://, azure://
|
||||
|
||||
Example:
|
||||
```
|
||||
from ray.llm._internal.common.callbacks.cloud_downloader import CloudDownloader
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
config = LLMConfig(
|
||||
...
|
||||
callback_config={
|
||||
"callback_class": CloudDownloader,
|
||||
"callback_kwargs": {
|
||||
"paths": [
|
||||
("s3://bucket/path/to/file.txt", "/local/path/to/file.txt"),
|
||||
("gs://bucket/path/to/file.txt", "/local/path/to/file.txt"),
|
||||
]
|
||||
}
|
||||
}
|
||||
...
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the CloudDownloader callback.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments passed to the callback as a dictionary.
|
||||
Must contain a 'paths' field with a list of (cloud_uri, local_path) tuples.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Validate configuration using Pydantic
|
||||
if "paths" not in self.kwargs:
|
||||
raise ValueError("CloudDownloader requires 'paths' field in kwargs")
|
||||
|
||||
CloudDownloaderConfig.model_validate(self.kwargs)
|
||||
|
||||
def on_before_download_model_files_distributed(self) -> None:
|
||||
"""Download files from cloud storage to local paths before model files are downloaded."""
|
||||
from ray.llm._internal.common.utils.cloud_utils import CloudFileSystem
|
||||
|
||||
paths = self.kwargs["paths"]
|
||||
start_time = time.monotonic()
|
||||
for cloud_uri, local_path in paths:
|
||||
CloudFileSystem.download_files(path=local_path, bucket_uri=cloud_uri)
|
||||
end_time = time.monotonic()
|
||||
logger.info(
|
||||
f"CloudDownloader: Files downloaded in {end_time - start_time} seconds"
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Generic constants for common utilities.
|
||||
|
||||
These constants are used by generic utilities and should not contain
|
||||
serve-specific or batch-specific values.
|
||||
"""
|
||||
|
||||
# Cloud object caching timeouts (in seconds)
|
||||
CLOUD_OBJECT_EXISTS_EXPIRE_S = 300 # 5 minutes
|
||||
CLOUD_OBJECT_MISSING_EXPIRE_S = 30 # 30 seconds
|
||||
|
||||
# LoRA adapter configuration file name
|
||||
LORA_ADAPTER_CONFIG_NAME = "adapter_config.json"
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def maybe_apply_llm_deployment_config_defaults(
|
||||
defaults: Dict[str, Any],
|
||||
user_deployment_config: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply defaults and merge with user-provided deployment config.
|
||||
|
||||
If the user has explicitly set 'num_replicas' in their deployment config,
|
||||
we remove 'autoscaling_config' from the defaults since Ray Serve
|
||||
does not allow both to be set simultaneously. Then merges the defaults
|
||||
with the user config.
|
||||
|
||||
Args:
|
||||
defaults: The default deployment options dictionary.
|
||||
user_deployment_config: The user-provided deployment configuration.
|
||||
|
||||
Returns:
|
||||
The merged deployment options with conflicts resolved.
|
||||
"""
|
||||
if user_deployment_config and "num_replicas" in user_deployment_config:
|
||||
defaults = defaults.copy()
|
||||
defaults.pop("autoscaling_config", None)
|
||||
return deep_merge_dicts(defaults, user_deployment_config or {})
|
||||
|
||||
|
||||
def deep_merge_dicts(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge two dictionaries hierarchically, creating a new dictionary without modifying inputs.
|
||||
|
||||
For each key:
|
||||
- If the key exists in both dicts and both values are dicts, recursively merge them
|
||||
- Otherwise, the value from override takes precedence
|
||||
|
||||
Args:
|
||||
base: The base dictionary
|
||||
override: The dictionary with values that should override the base
|
||||
|
||||
Returns:
|
||||
A new merged dictionary
|
||||
|
||||
Example:
|
||||
>>> base = {"a": 1, "b": {"c": 2, "d": 3}}
|
||||
>>> override = {"b": {"c": 10}, "e": 5}
|
||||
>>> result = deep_merge_dicts(base, override)
|
||||
>>> result
|
||||
{'a': 1, 'b': {'c': 10, 'd': 3}, 'e': 5}
|
||||
"""
|
||||
result = base.copy()
|
||||
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
result[key] = deep_merge_dicts(result[key], value)
|
||||
else:
|
||||
# Override the value (or add new key)
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Fatal engine error definitions shared by serve and batch layers."""
|
||||
|
||||
from typing import Tuple, Type
|
||||
|
||||
# vLLM fatal errors that should always be re-raised, never swallowed.
|
||||
# EngineDeadError indicates the vLLM engine process has crashed and is
|
||||
# unrecoverable — all subsequent requests would fail anyway.
|
||||
VLLM_FATAL_ERRORS: Tuple[Type[Exception], ...] = ()
|
||||
try:
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
VLLM_FATAL_ERRORS = (EngineDeadError,)
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Generic model definitions for common utilities.
|
||||
|
||||
These models represent generic concepts that can be used by both
|
||||
serve and batch components.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from functools import partial
|
||||
from typing import Awaitable, Callable, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# DiskMultiplexConfig removed - it's serve-specific and belongs in serve/configs/server_models.py
|
||||
|
||||
|
||||
class GlobalIdManager:
|
||||
"""Thread-safe global ID manager for assigning unique IDs."""
|
||||
|
||||
def __init__(self):
|
||||
self._counter = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def next(self) -> int:
|
||||
"""Get the next unique ID."""
|
||||
with self._lock:
|
||||
self._counter += 1
|
||||
return self._counter
|
||||
|
||||
|
||||
# Global instance
|
||||
global_id_manager = GlobalIdManager()
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ray._common.filters import CoreContextFilter
|
||||
|
||||
|
||||
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 logger is already setup and has handlers.
|
||||
|
||||
Args:
|
||||
logger_name: logger name used to get the logger.
|
||||
"""
|
||||
logger = logging.getLogger(logger_name)
|
||||
llm_logger = logging.getLogger("ray.llm")
|
||||
|
||||
# Skip setup if the logger already has handlers setup or if the parent (Data
|
||||
# logger) has handlers.
|
||||
if not (logger.handlers or llm_logger.handlers):
|
||||
# Set up stream handler, which logs to console as plaintext.
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.addFilter(CoreContextFilter())
|
||||
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 Data logger.
|
||||
|
||||
Loggers by default are logging to stdout, and are expected to be scraped by an
|
||||
external process.
|
||||
"""
|
||||
logger_name = f"ray.llm.{name}"
|
||||
_setup_logger(logger_name)
|
||||
return logging.getLogger(logger_name)
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
|
||||
from ray._private.ray_logging.filters import CoreContextFilter
|
||||
from ray._private.ray_logging.formatters import JSONFormatter
|
||||
|
||||
|
||||
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.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,48 @@
|
||||
"""Utilities for logging."""
|
||||
|
||||
import logging
|
||||
|
||||
import ray
|
||||
|
||||
|
||||
def disable_vllm_custom_ops_logger_on_cpu_nodes():
|
||||
"""This disables a log line in the "vllm._custom_ops" logger on CPU nodes.
|
||||
|
||||
vllm._custom_ops is automatically imported when vllm is imported. It checks
|
||||
for CUDA binaries that don't exist in CPU-only nodes. This makes rayllm
|
||||
raise a scary-looking (but harmless) warning when imported on CPU nodes,
|
||||
such as when running the generate_config.py script or running the
|
||||
build-app task.
|
||||
"""
|
||||
|
||||
class SkipVLLMWarningFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord):
|
||||
"""Only allow CRITICAL logs from the datasets/config.py file."""
|
||||
|
||||
log_fragment = "Failed to import from vllm._C with"
|
||||
|
||||
return log_fragment not in record.getMessage()
|
||||
|
||||
if not ray.is_initialized() or len(ray.get_gpu_ids()) == 0:
|
||||
logging.getLogger("vllm._custom_ops").addFilter(SkipVLLMWarningFilter())
|
||||
|
||||
|
||||
def disable_datasets_logger():
|
||||
"""This disables "datasets" logs from its "config.py" file.
|
||||
|
||||
Upon import, rayllm imports vllm which calls datasets. The datasets package
|
||||
emits a log from its config.py file.
|
||||
|
||||
The file that emits this log uses the root "datasets" logger, so we use a
|
||||
filter to prevent logs from only the config.py file.
|
||||
"""
|
||||
|
||||
class SkipDatasetsConfigLogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord):
|
||||
"""Only allow CRITICAL logs from the datasets/config.py file."""
|
||||
return (
|
||||
record.levelno >= logging.CRITICAL
|
||||
or "datasets/config.py" not in record.pathname
|
||||
)
|
||||
|
||||
logging.getLogger("datasets").addFilter(SkipDatasetsConfigLogFilter())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Utilities for telemetry."""
|
||||
|
||||
from threading import Lock
|
||||
from typing import Callable
|
||||
|
||||
DEFAULT_GPU_TYPE = "UNSPECIFIED"
|
||||
|
||||
|
||||
class Once:
|
||||
"""Execute a function exactly once and block all callers until the function returns
|
||||
|
||||
Same as golang's `sync.Once <https://pkg.go.dev/sync#Once>`_
|
||||
|
||||
Took this directly from OpenTelemetry's Python SDK:
|
||||
Ref: https://github.com/open-telemetry/opentelemetry-python/blob
|
||||
/c6fab7d4c339dc5bf9eb9ef2723caad09d69bfca/opentelemetry-api/src/opentelemetry
|
||||
/util/_once.py
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = Lock()
|
||||
self._done = False
|
||||
|
||||
def do_once(self, func: Callable[[], None]) -> bool:
|
||||
"""Execute ``func`` if it hasn't been executed or return.
|
||||
|
||||
Will block until ``func`` has been called by one thread.
|
||||
|
||||
Args:
|
||||
func: The function to execute exactly once.
|
||||
|
||||
Returns:
|
||||
Whether or not ``func`` was executed in this call
|
||||
"""
|
||||
|
||||
# fast path, try to avoid locking
|
||||
if self._done:
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
if not self._done:
|
||||
func()
|
||||
self._done = True
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Temporary runtime patches of third-party libraries (currently vLLM).
|
||||
|
||||
Each module here is a stopgap for behavior Ray Serve LLM needs before it exists
|
||||
upstream. The goal is for this package to trend toward empty: every patch states
|
||||
its removal condition in its module docstring and tracks the upstream work that
|
||||
will retire it. Patches are deleted as soon as that upstream lands.
|
||||
"""
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Runtime patches of vLLM internals. See the parent package policy: each patch
|
||||
is temporary and tracked to an upstream issue/PR for removal."""
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Decode-stage reuse of prefill's prompt token ids (P/D tokenize-once).
|
||||
|
||||
A P/D chat prompt is otherwise tokenized once per stage. ``install()`` wraps
|
||||
``BaseRenderer.tokenize_prompts_async`` to inject the ids prefill already produced.
|
||||
``reuse_prompt_token_ids`` publishes those ids per async task. vLLM skips the encode
|
||||
when ``prompt_token_ids`` is present, so the rest of the pipeline runs unchanged.
|
||||
``install()`` is idempotent and fails safe.
|
||||
|
||||
Decode tokenization must run within the reuse block, on the same async task or one
|
||||
spawned from it, so the contextvar reaches it.
|
||||
|
||||
Temporary patch. The intended end state is native pre-tokenized input on the
|
||||
chat-completions path (a ``prompt_token_ids`` field on ``ChatCompletionRequest``),
|
||||
at which point decode passes the ids through a request field and this wrap is
|
||||
deleted. See vllm-project/vllm#22817 (token-in/token-out) for the upstream
|
||||
direction.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import contextvars
|
||||
import functools
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-async-task reused prompt token ids. contextvars don't leak across tasks, so
|
||||
# concurrent requests can't cross-contaminate.
|
||||
_reused_token_ids: contextvars.ContextVar = contextvars.ContextVar(
|
||||
"pd_reused_prompt_token_ids", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def reuse_prompt_token_ids(token_ids):
|
||||
"""Publish ``token_ids`` so the chat render inside this block skips tokenize.
|
||||
|
||||
No-op when ``token_ids`` is falsy, so callers need no separate enabled check.
|
||||
"""
|
||||
if not token_ids:
|
||||
yield
|
||||
return
|
||||
_reused_token_ids.set(list(token_ids))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Use set(None), not reset(token). The finally may run in a different
|
||||
# Context than the enter during off-task generator finalization, where
|
||||
# reset() would raise.
|
||||
_reused_token_ids.set(None)
|
||||
|
||||
|
||||
def install() -> bool:
|
||||
"""Wrap ``BaseRenderer.tokenize_prompts_async`` to honor the contextvar.
|
||||
|
||||
Idempotent and fails safe. Returns False and leaves tokenization untouched when
|
||||
vLLM's renderer is missing or differs, so it never crashes startup.
|
||||
"""
|
||||
try:
|
||||
from vllm.renderers.base import BaseRenderer
|
||||
|
||||
orig = getattr(BaseRenderer, "tokenize_prompts_async", None)
|
||||
if orig is None:
|
||||
logger.debug("pd-tokenize-once: BaseRenderer.tokenize_prompts_async absent")
|
||||
return False
|
||||
if getattr(orig, "_pd_tokonce_wrapped", False):
|
||||
return True
|
||||
|
||||
@functools.wraps(orig)
|
||||
async def tokenize_prompts_async(self, prompts, params, *args, **kwargs):
|
||||
ids = _reused_token_ids.get()
|
||||
# Inject the reused ids into the lone rendered prompt so vLLM skips the
|
||||
# encode but still preserves multi_modal_data and runs detok/validation.
|
||||
# Anything else falls through to a normal tokenize.
|
||||
if (
|
||||
ids
|
||||
and len(prompts) == 1
|
||||
and isinstance(prompts[0], dict)
|
||||
and "prompt_token_ids" not in prompts[0]
|
||||
and "prompt_embeds" not in prompts[0]
|
||||
and "encoder_prompt" not in prompts[0]
|
||||
):
|
||||
prompts = [{**prompts[0], "prompt_token_ids": ids}]
|
||||
return await orig(self, prompts, params, *args, **kwargs)
|
||||
|
||||
tokenize_prompts_async._pd_tokonce_wrapped = True
|
||||
BaseRenderer.tokenize_prompts_async = tokenize_prompts_async
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug("pd-tokenize-once: install failed (%s)", e)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"pd-tokenize-once: wrapped BaseRenderer.tokenize_prompts_async "
|
||||
"(decode stage will reuse prefill's prompt token ids)"
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import ConfigDict, Field, model_validator
|
||||
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
|
||||
|
||||
class BundleConfig(BaseModelExtended):
|
||||
"""Configuration for a single placement group bundle.
|
||||
|
||||
Resource counts are floats to align with Ray's internal resource
|
||||
representation, which supports fractional values (e.g. GPU=0.5).
|
||||
Both CPU and GPU default to 0.0 — the schema does not inject hidden
|
||||
resource requests. Extra fields are allowed for custom Ray resources (e.g. TPU,
|
||||
accelerator_type:L4).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
CPU: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="The number of CPUs per bundle.",
|
||||
)
|
||||
GPU: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="The number of GPUs per bundle.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_extra_resources(self):
|
||||
"""Ensure custom resources (TPU, accelerator_type, etc.) are non-negative."""
|
||||
extra_resources = self.model_extra
|
||||
if not extra_resources:
|
||||
return self
|
||||
for key, value in extra_resources.items():
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(
|
||||
f"Resource '{key}' must be a number, got {type(value).__name__}"
|
||||
)
|
||||
if value < 0:
|
||||
raise ValueError(f"Resource '{key}' must be non-negative, got {value}")
|
||||
return self
|
||||
|
||||
|
||||
class PlacementGroupConfig(BaseModelExtended):
|
||||
"""Configuration for placement group."""
|
||||
|
||||
bundle_per_worker: Optional[BundleConfig] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Resource bundle specification for each worker. "
|
||||
"Auto-replicated based on tensor_parallel_size * pipeline_parallel_size. "
|
||||
"Cannot be used together with 'bundles'."
|
||||
),
|
||||
)
|
||||
bundles: Optional[List[BundleConfig]] = Field(
|
||||
default=None, description="List of resource bundles"
|
||||
)
|
||||
strategy: Literal["PACK", "SPREAD", "STRICT_PACK", "STRICT_SPREAD"] = Field(
|
||||
default="PACK", description="Placement group strategy"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_bundle_options(self):
|
||||
if self.bundle_per_worker is not None and self.bundles is not None:
|
||||
raise ValueError(
|
||||
"Cannot specify both 'bundle_per_worker' and 'bundles' in "
|
||||
"placement_group_config. Use 'bundle_per_worker' for simple "
|
||||
"per-worker resource specification (auto-replicated by tp*pp), "
|
||||
"or 'bundles' for full control."
|
||||
)
|
||||
if self.bundle_per_worker is None and self.bundles is None:
|
||||
raise ValueError(
|
||||
"placement_group_config must specify either 'bundle_per_worker' "
|
||||
"or 'bundles'."
|
||||
)
|
||||
return self
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Cloud filesystem module for provider-specific implementations.
|
||||
|
||||
This module provides a unified interface for cloud storage operations across
|
||||
different providers (S3, GCS, Azure) while allowing provider-specific optimizations.
|
||||
"""
|
||||
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.azure_filesystem import (
|
||||
AzureFileSystem,
|
||||
)
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.base import (
|
||||
BaseCloudFileSystem,
|
||||
)
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.gcs_filesystem import (
|
||||
GCSFileSystem,
|
||||
)
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.pyarrow_filesystem import (
|
||||
PyArrowFileSystem,
|
||||
)
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.s3_filesystem import (
|
||||
S3FileSystem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseCloudFileSystem",
|
||||
"PyArrowFileSystem",
|
||||
"GCSFileSystem",
|
||||
"AzureFileSystem",
|
||||
"S3FileSystem",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Azure-specific filesystem implementation.
|
||||
|
||||
This module provides an Azure-specific implementation that delegates to PyArrowFileSystem.
|
||||
This maintains backward compatibility while allowing for future optimizations using
|
||||
native Azure tools (azcopy, azure-storage-blob SDK).
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.base import BaseCloudFileSystem
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.pyarrow_filesystem import (
|
||||
PyArrowFileSystem,
|
||||
)
|
||||
|
||||
|
||||
class AzureFileSystem(BaseCloudFileSystem):
|
||||
"""Azure-specific implementation of cloud filesystem operations.
|
||||
|
||||
**Note**: This implementation currently delegates to PyArrowFileSystem to maintain
|
||||
stability. Optimized implementation using azure-storage-blob SDK and azcopy
|
||||
will be added in a future PR.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (abfss:// or azure://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
return PyArrowFileSystem.get_file(object_uri, decode_as_utf_8)
|
||||
|
||||
@staticmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (abfss:// or azure://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
return PyArrowFileSystem.list_subfolders(folder_uri)
|
||||
|
||||
@staticmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download (e.g .safetensors)
|
||||
"""
|
||||
PyArrowFileSystem.download_files(
|
||||
path, bucket_uri, substrings_to_include, suffixes_to_exclude
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with
|
||||
`abfss://` or `azure://`.
|
||||
"""
|
||||
PyArrowFileSystem.upload_files(local_path, bucket_uri)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Abstract base class for cloud filesystem implementations.
|
||||
|
||||
This module defines the interface that all cloud storage provider implementations
|
||||
must follow, ensuring consistency across different providers while allowing
|
||||
provider-specific optimizations.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Union
|
||||
|
||||
|
||||
class BaseCloudFileSystem(ABC):
|
||||
"""Abstract base class for cloud filesystem implementations.
|
||||
|
||||
This class defines the interface that all cloud storage provider implementations
|
||||
must implement. Provider-specific classes (S3FileSystem, GCSFileSystem, etc.)
|
||||
will inherit from this base class and provide optimized implementations for
|
||||
their respective cloud storage platforms.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (s3://, gs://, abfss://, or azure://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (s3://, gs://, abfss://, or azure://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download (e.g .safetensors)
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with
|
||||
`s3://`, `gs://`, `abfss://`, or `azure://`.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,81 @@
|
||||
"""GCS-specific filesystem implementation.
|
||||
|
||||
This module provides a GCS-specific implementation.
|
||||
This maintains backward compatibility while allowing for future optimizations using
|
||||
native GCS tools (gsutil, google-cloud-storage SDK).
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.base import BaseCloudFileSystem
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.pyarrow_filesystem import (
|
||||
PyArrowFileSystem,
|
||||
)
|
||||
|
||||
|
||||
class GCSFileSystem(BaseCloudFileSystem):
|
||||
"""GCS-specific implementation of cloud filesystem operations.
|
||||
|
||||
**Note**: This implementation currently delegates to PyArrowFileSystem to maintain
|
||||
stability. Optimized implementation using google-cloud-storage SDK and gsutil
|
||||
will be added in a future PR.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (gs://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
return PyArrowFileSystem.get_file(object_uri, decode_as_utf_8)
|
||||
|
||||
@staticmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (gs://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
return PyArrowFileSystem.list_subfolders(folder_uri)
|
||||
|
||||
@staticmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download (e.g .safetensors)
|
||||
"""
|
||||
PyArrowFileSystem.download_files(
|
||||
path, bucket_uri, substrings_to_include, suffixes_to_exclude
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with `gs://`.
|
||||
"""
|
||||
PyArrowFileSystem.upload_files(local_path, bucket_uri)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""PyArrow-based filesystem implementation for cloud storage.
|
||||
|
||||
This module provides a PyArrow-based implementation of the cloud filesystem
|
||||
interface, supporting S3, GCS, and Azure storage providers.
|
||||
"""
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pyarrow.fs as pa_fs
|
||||
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.base import BaseCloudFileSystem
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PyArrowFileSystem(BaseCloudFileSystem):
|
||||
"""PyArrow-based implementation of cloud filesystem operations.
|
||||
|
||||
This class provides a unified interface for cloud storage operations using
|
||||
PyArrow's filesystem abstraction. It supports S3, GCS, and Azure storage
|
||||
providers.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_fs_and_path(object_uri: str) -> Tuple[pa_fs.FileSystem, str]:
|
||||
"""Get the appropriate filesystem and path from a URI.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (s3://, gs://, abfss://, or azure://)
|
||||
If URI contains 'anonymous@', anonymous access is used.
|
||||
Example: s3://anonymous@bucket/path
|
||||
|
||||
Returns:
|
||||
Tuple of (filesystem, path)
|
||||
"""
|
||||
|
||||
if object_uri.startswith("pyarrow-"):
|
||||
object_uri = object_uri[8:]
|
||||
|
||||
anonymous = False
|
||||
# Check for anonymous access pattern (only for S3/GCS)
|
||||
# e.g. s3://anonymous@bucket/path
|
||||
if "@" in object_uri and not (
|
||||
object_uri.startswith("abfss://") or object_uri.startswith("azure://")
|
||||
):
|
||||
parts = object_uri.split("@", 1)
|
||||
# Check if the first part ends with "anonymous"
|
||||
if parts[0].endswith("anonymous"):
|
||||
anonymous = True
|
||||
# Remove the anonymous@ part, keeping the scheme
|
||||
scheme = parts[0].split("://")[0]
|
||||
object_uri = f"{scheme}://{parts[1]}"
|
||||
|
||||
if object_uri.startswith("s3://"):
|
||||
endpoint = os.getenv("AWS_ENDPOINT_URL_S3", None)
|
||||
virtual_hosted_style = os.getenv("AWS_S3_ADDRESSING_STYLE", None)
|
||||
fs = pa_fs.S3FileSystem(
|
||||
anonymous=anonymous,
|
||||
endpoint_override=endpoint,
|
||||
force_virtual_addressing=(virtual_hosted_style == "virtual"),
|
||||
)
|
||||
path = object_uri[5:] # Remove "s3://"
|
||||
elif object_uri.startswith("gs://"):
|
||||
fs = pa_fs.GcsFileSystem(anonymous=anonymous)
|
||||
path = object_uri[5:] # Remove "gs://"
|
||||
elif object_uri.startswith("abfss://"):
|
||||
fs, path = PyArrowFileSystem._create_abfss_filesystem(object_uri)
|
||||
elif object_uri.startswith("azure://"):
|
||||
fs, path = PyArrowFileSystem._create_azure_filesystem(object_uri)
|
||||
else:
|
||||
raise ValueError(f"Unsupported URI scheme: {object_uri}")
|
||||
|
||||
return fs, path
|
||||
|
||||
@staticmethod
|
||||
def _create_azure_filesystem(object_uri: str) -> Tuple[pa_fs.FileSystem, str]:
|
||||
"""Create an Azure filesystem for Azure Blob Storage or ABFSS.
|
||||
|
||||
Args:
|
||||
object_uri: Azure URI (azure://container@account.blob.core.windows.net/path or
|
||||
abfss://container@account.dfs.core.windows.net/path)
|
||||
|
||||
Returns:
|
||||
Tuple of (PyArrow FileSystem, path without scheme prefix)
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies are not installed.
|
||||
ValueError: If the Azure URI format is invalid.
|
||||
"""
|
||||
try:
|
||||
import adlfs
|
||||
from azure.identity import DefaultAzureCredential
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"You must `pip install adlfs azure-identity` "
|
||||
"to use Azure/ABFSS URIs. "
|
||||
"Note that these must be preinstalled on all nodes in the Ray cluster."
|
||||
)
|
||||
|
||||
# Parse and validate the Azure URI
|
||||
parsed = urlparse(object_uri)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# Validate URI format: scheme://container@account.domain/path
|
||||
if not parsed.netloc or "@" not in parsed.netloc:
|
||||
raise ValueError(
|
||||
f"Invalid {scheme.upper()} URI format - missing container@account: {object_uri}"
|
||||
)
|
||||
|
||||
container_part, hostname_part = parsed.netloc.split("@", 1)
|
||||
|
||||
# Validate container name (must be non-empty)
|
||||
if not container_part:
|
||||
raise ValueError(
|
||||
f"Invalid {scheme.upper()} URI format - empty container name: {object_uri}"
|
||||
)
|
||||
|
||||
# Validate hostname format based on scheme
|
||||
valid_hostname = False
|
||||
if scheme == "abfss":
|
||||
valid_hostname = hostname_part.endswith(".dfs.core.windows.net")
|
||||
expected_domains = ".dfs.core.windows.net"
|
||||
elif scheme == "azure":
|
||||
valid_hostname = hostname_part.endswith(
|
||||
".blob.core.windows.net"
|
||||
) or hostname_part.endswith(".dfs.core.windows.net")
|
||||
expected_domains = ".blob.core.windows.net or .dfs.core.windows.net"
|
||||
|
||||
if not hostname_part or not valid_hostname:
|
||||
raise ValueError(
|
||||
f"Invalid {scheme.upper()} URI format - invalid hostname (must end with {expected_domains}): {object_uri}"
|
||||
)
|
||||
|
||||
# Extract and validate account name
|
||||
azure_storage_account_name = hostname_part.split(".")[0]
|
||||
if not azure_storage_account_name:
|
||||
raise ValueError(
|
||||
f"Invalid {scheme.upper()} URI format - empty account name: {object_uri}"
|
||||
)
|
||||
|
||||
# Create the adlfs filesystem
|
||||
adlfs_fs = adlfs.AzureBlobFileSystem(
|
||||
account_name=azure_storage_account_name,
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
# Wrap with PyArrow's PyFileSystem for compatibility
|
||||
fs = pa_fs.PyFileSystem(pa_fs.FSSpecHandler(adlfs_fs))
|
||||
|
||||
# Return the path without the scheme prefix
|
||||
path = f"{container_part}{parsed.path}"
|
||||
|
||||
return fs, path
|
||||
|
||||
@staticmethod
|
||||
def _create_abfss_filesystem(object_uri: str) -> Tuple[pa_fs.FileSystem, str]:
|
||||
"""Create an ABFSS filesystem for Azure Data Lake Storage Gen2.
|
||||
|
||||
This is a wrapper around _create_azure_filesystem for backward compatibility.
|
||||
|
||||
Args:
|
||||
object_uri: ABFSS URI (abfss://container@account.dfs.core.windows.net/path)
|
||||
|
||||
Returns:
|
||||
Tuple of (PyArrow FileSystem, path without abfss:// prefix)
|
||||
"""
|
||||
return PyArrowFileSystem._create_azure_filesystem(object_uri)
|
||||
|
||||
@staticmethod
|
||||
def _filter_files(
|
||||
fs: pa_fs.FileSystem,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""Filter files from cloud storage based on inclusion and exclusion criteria.
|
||||
|
||||
Args:
|
||||
fs: PyArrow filesystem instance
|
||||
source_path: Source path in cloud storage
|
||||
destination_path: Local destination path
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude files ending with these suffixes
|
||||
|
||||
Returns:
|
||||
List of tuples containing (source_file_path, destination_file_path)
|
||||
"""
|
||||
file_selector = pa_fs.FileSelector(source_path, recursive=True)
|
||||
file_infos = fs.get_file_info(file_selector)
|
||||
|
||||
path_pairs = []
|
||||
for file_info in file_infos:
|
||||
if file_info.type != pa_fs.FileType.File:
|
||||
continue
|
||||
|
||||
rel_path = file_info.path[len(source_path) :].lstrip("/")
|
||||
|
||||
# Apply filters
|
||||
if substrings_to_include:
|
||||
if not any(
|
||||
substring in rel_path for substring in substrings_to_include
|
||||
):
|
||||
continue
|
||||
|
||||
if suffixes_to_exclude:
|
||||
if any(rel_path.endswith(suffix) for suffix in suffixes_to_exclude):
|
||||
continue
|
||||
|
||||
path_pairs.append(
|
||||
(file_info.path, os.path.join(destination_path, rel_path))
|
||||
)
|
||||
|
||||
return path_pairs
|
||||
|
||||
@staticmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (s3://, gs://, abfss://, or azure://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
try:
|
||||
fs, path = PyArrowFileSystem.get_fs_and_path(object_uri)
|
||||
|
||||
# Check if file exists
|
||||
if not fs.get_file_info(path).type == pa_fs.FileType.File:
|
||||
logger.info(f"URI {object_uri} does not exist.")
|
||||
return None
|
||||
|
||||
# Read file
|
||||
with fs.open_input_file(path) as f:
|
||||
body = f.read()
|
||||
|
||||
if decode_as_utf_8:
|
||||
body = body.decode("utf-8")
|
||||
return body
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading {object_uri}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (s3://, gs://, abfss://, or azure://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
# Ensure that the folder_uri has a trailing slash.
|
||||
folder_uri = f"{folder_uri.rstrip('/')}/"
|
||||
|
||||
try:
|
||||
fs, path = PyArrowFileSystem.get_fs_and_path(folder_uri)
|
||||
|
||||
# List directory contents
|
||||
file_infos = fs.get_file_info(pa_fs.FileSelector(path, recursive=False))
|
||||
|
||||
# Filter for directories and extract subfolder names
|
||||
subfolders = []
|
||||
for file_info in file_infos:
|
||||
if file_info.type == pa_fs.FileType.Directory:
|
||||
# Extract just the subfolder name without the full path
|
||||
subfolder = os.path.basename(file_info.path.rstrip("/"))
|
||||
subfolders.append(subfolder)
|
||||
|
||||
return subfolders
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing subfolders in {folder_uri}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
max_concurrency: int = 10,
|
||||
chunk_size: int = 64 * 1024 * 1024,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download
|
||||
max_concurrency: Maximum number of concurrent files to download (default: 10)
|
||||
chunk_size: Size of transfer chunks (default: 64MB)
|
||||
"""
|
||||
try:
|
||||
fs, source_path = PyArrowFileSystem.get_fs_and_path(bucket_uri)
|
||||
|
||||
# Ensure destination exists
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
# If no filters, use direct copy_files
|
||||
if not substrings_to_include and not suffixes_to_exclude:
|
||||
pa_fs.copy_files(
|
||||
source=source_path,
|
||||
destination=path,
|
||||
source_filesystem=fs,
|
||||
destination_filesystem=pa_fs.LocalFileSystem(),
|
||||
use_threads=True,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
return
|
||||
|
||||
# List and filter files
|
||||
files_to_download = PyArrowFileSystem._filter_files(
|
||||
fs, source_path, path, substrings_to_include, suffixes_to_exclude
|
||||
)
|
||||
|
||||
if not files_to_download:
|
||||
logger.info("Filters do not match any of the files, skipping download")
|
||||
return
|
||||
|
||||
def download_single_file(file_paths):
|
||||
source_file_path, dest_file_path = file_paths
|
||||
# Create destination directory if needed
|
||||
dest_dir = os.path.dirname(dest_file_path)
|
||||
if dest_dir:
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
# Use PyArrow's copy_files for individual files,
|
||||
pa_fs.copy_files(
|
||||
source=source_file_path,
|
||||
destination=dest_file_path,
|
||||
source_filesystem=fs,
|
||||
destination_filesystem=pa_fs.LocalFileSystem(),
|
||||
use_threads=True,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
return dest_file_path
|
||||
|
||||
max_workers = min(max_concurrency, len(files_to_download))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(download_single_file, file_paths)
|
||||
for file_paths in files_to_download
|
||||
]
|
||||
|
||||
for future in futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download file: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading files from {bucket_uri}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with
|
||||
`s3://`, `gs://`, `abfss://`, or `azure://`.
|
||||
"""
|
||||
try:
|
||||
fs, dest_path = PyArrowFileSystem.get_fs_and_path(bucket_uri)
|
||||
|
||||
pa_fs.copy_files(
|
||||
source=local_path,
|
||||
destination=dest_path,
|
||||
source_filesystem=pa_fs.LocalFileSystem(),
|
||||
destination_filesystem=fs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error uploading files to {bucket_uri}: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,397 @@
|
||||
"""S3-specific filesystem implementation using boto3.
|
||||
|
||||
This module provides an S3-specific implementation that uses boto3 (AWS SDK for Python)
|
||||
for reliable and efficient S3 operations.
|
||||
"""
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
try:
|
||||
import boto3
|
||||
from botocore import UNSIGNED
|
||||
from botocore.config import Config
|
||||
except ImportError:
|
||||
boto3 = None # type: ignore[assignment]
|
||||
UNSIGNED = None # type: ignore[assignment]
|
||||
Config = None # type: ignore[assignment]
|
||||
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_filesystem.base import BaseCloudFileSystem
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _check_boto3() -> None:
|
||||
"""Raise a clear error if boto3/botocore are not installed."""
|
||||
if boto3 is None:
|
||||
raise ImportError(
|
||||
"boto3 and botocore are required for S3 operations but are not installed. "
|
||||
"Install them with: pip install boto3"
|
||||
)
|
||||
|
||||
|
||||
class S3FileSystem(BaseCloudFileSystem):
|
||||
"""S3-specific implementation of cloud filesystem operations using boto3.
|
||||
|
||||
This implementation uses boto3 (AWS SDK for Python) for reliable and efficient
|
||||
operations with S3 storage.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _parse_s3_uri(uri: str) -> tuple[str, str, bool]:
|
||||
"""Parse S3 URI into bucket and key.
|
||||
|
||||
Args:
|
||||
uri: S3 URI (e.g., s3://bucket/path/to/object or s3://anonymous@bucket/path/to/object)
|
||||
|
||||
Returns:
|
||||
Tuple of (bucket_name, key, is_anonymous)
|
||||
|
||||
Raises:
|
||||
ValueError: If URI is not a valid S3 URI
|
||||
"""
|
||||
# Check if anonymous@ prefix exists
|
||||
is_anonymous = False
|
||||
if uri.startswith("s3://anonymous@"):
|
||||
is_anonymous = True
|
||||
uri = uri.replace("s3://anonymous@", "s3://", 1)
|
||||
|
||||
if not uri.startswith("s3://"):
|
||||
raise ValueError(f"Invalid S3 URI: {uri}")
|
||||
|
||||
# Remove s3:// prefix and split into bucket and key
|
||||
path = uri[5:] # Remove "s3://"
|
||||
parts = path.split("/", 1)
|
||||
bucket = parts[0]
|
||||
key = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
return bucket, key, is_anonymous
|
||||
|
||||
@staticmethod
|
||||
def _get_s3_client(max_pool_connections: int = 50, anonymous: bool = False):
|
||||
"""Create a new S3 client instance with connection pooling.
|
||||
|
||||
Args:
|
||||
max_pool_connections: Maximum number of connections in the pool.
|
||||
Should be >= max_workers for optimal performance.
|
||||
anonymous: Whether to use anonymous access to S3.
|
||||
|
||||
Returns:
|
||||
boto3 S3 client with connection pooling configured
|
||||
"""
|
||||
_check_boto3()
|
||||
# Configure connection pooling for better concurrent performance
|
||||
config = Config(
|
||||
max_pool_connections=max_pool_connections,
|
||||
# Retry configuration for transient failures
|
||||
retries={
|
||||
"max_attempts": 3,
|
||||
"mode": "adaptive", # Adapts retry behavior based on error type
|
||||
},
|
||||
# TCP keepalive helps with long-running connections
|
||||
tcp_keepalive=True,
|
||||
signature_version=UNSIGNED if anonymous else None,
|
||||
)
|
||||
|
||||
return boto3.client("s3", config=config)
|
||||
|
||||
@staticmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (s3://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
_check_boto3()
|
||||
try:
|
||||
bucket, key, is_anonymous = S3FileSystem._parse_s3_uri(object_uri)
|
||||
s3_client = S3FileSystem._get_s3_client(anonymous=is_anonymous)
|
||||
|
||||
# Download file directly into memory
|
||||
response = s3_client.get_object(Bucket=bucket, Key=key)
|
||||
body = response["Body"].read()
|
||||
|
||||
if decode_as_utf_8:
|
||||
return body.decode("utf-8")
|
||||
return body
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading {object_uri}: {e}")
|
||||
|
||||
@staticmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (s3://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
_check_boto3()
|
||||
try:
|
||||
bucket, prefix, is_anonymous = S3FileSystem._parse_s3_uri(folder_uri)
|
||||
|
||||
# Ensure that the prefix has a trailing slash
|
||||
if prefix and not prefix.endswith("/"):
|
||||
prefix = f"{prefix}/"
|
||||
|
||||
s3_client = S3FileSystem._get_s3_client(anonymous=is_anonymous)
|
||||
|
||||
# List objects with delimiter to get only immediate subfolders
|
||||
response = s3_client.list_objects_v2(
|
||||
Bucket=bucket, Prefix=prefix, Delimiter="/"
|
||||
)
|
||||
|
||||
subfolders = []
|
||||
# CommonPrefixes contains the subdirectories
|
||||
for common_prefix in response.get("CommonPrefixes", []):
|
||||
folder_path = common_prefix["Prefix"]
|
||||
# Extract the folder name from the full prefix
|
||||
# Remove the parent prefix and trailing slash
|
||||
folder_name = folder_path[len(prefix) :].rstrip("/")
|
||||
if folder_name:
|
||||
subfolders.append(folder_name)
|
||||
|
||||
return subfolders
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing subfolders in {folder_uri}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _calculate_optimal_workers(
|
||||
num_files: int, total_size: int, default_max: int = 100, default_min: int = 10
|
||||
) -> int:
|
||||
"""Calculate optimal number of workers based on file characteristics.
|
||||
|
||||
Args:
|
||||
num_files: Number of files to download
|
||||
total_size: Total size of all files in bytes
|
||||
default_max: Maximum workers to cap at
|
||||
default_min: Minimum workers to use
|
||||
|
||||
Returns:
|
||||
Optimal number of workers between default_min and default_max
|
||||
"""
|
||||
if num_files == 0:
|
||||
return default_min
|
||||
|
||||
avg_file_size = total_size / num_files if total_size > 0 else 0
|
||||
|
||||
# Strategy: More workers for smaller files, fewer for larger files
|
||||
if avg_file_size < 1024 * 1024: # < 1MB (small files)
|
||||
# Use more workers for many small files
|
||||
workers = min(num_files, default_max)
|
||||
elif avg_file_size < 10 * 1024 * 1024: # 1-10MB (medium files)
|
||||
# Use moderate workers
|
||||
workers = min(num_files // 2, default_max // 2)
|
||||
else: # > 10MB (large files)
|
||||
# Use fewer workers since each download is bandwidth-intensive
|
||||
workers = min(20, num_files)
|
||||
|
||||
# Ensure workers is between min and max
|
||||
return max(default_min, min(workers, default_max))
|
||||
|
||||
@staticmethod
|
||||
def _download_single_file(
|
||||
s3_client: Any, bucket: str, key: str, local_file_path: str
|
||||
) -> tuple[str, bool]:
|
||||
"""Download a single file from S3.
|
||||
|
||||
Args:
|
||||
s3_client: Shared boto3 S3 client
|
||||
bucket: S3 bucket name
|
||||
key: S3 object key
|
||||
local_file_path: Local path where file will be saved
|
||||
|
||||
Returns:
|
||||
Tuple of (key, success)
|
||||
"""
|
||||
try:
|
||||
# Create parent directories if needed
|
||||
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
||||
|
||||
s3_client.download_file(bucket, key, local_file_path)
|
||||
logger.debug(f"Downloaded {key} to {local_file_path}")
|
||||
return key, True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download {key}: {e}")
|
||||
return key, False
|
||||
|
||||
@staticmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory concurrently.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download (e.g .safetensors)
|
||||
max_workers: Maximum number of concurrent downloads. If None, automatically
|
||||
calculated based on file count and sizes (min: 10, max: 100)
|
||||
"""
|
||||
try:
|
||||
bucket, prefix, is_anonymous = S3FileSystem._parse_s3_uri(bucket_uri)
|
||||
|
||||
# Ensure the destination directory exists
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
# Ensure prefix has trailing slash for directory listing
|
||||
if prefix and not prefix.endswith("/"):
|
||||
prefix = f"{prefix}/"
|
||||
|
||||
# Create initial client for listing (will recreate with proper pool size later)
|
||||
s3_client = S3FileSystem._get_s3_client(anonymous=is_anonymous)
|
||||
|
||||
# List all objects in the bucket with the given prefix
|
||||
paginator = s3_client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=bucket, Prefix=prefix)
|
||||
|
||||
# Collect all files to download and track total size
|
||||
files_to_download = []
|
||||
total_size = 0
|
||||
|
||||
for page in pages:
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
size = obj.get("Size", 0)
|
||||
|
||||
# Skip if it's a directory marker
|
||||
if key.endswith("/"):
|
||||
continue
|
||||
|
||||
# Get the relative path (remove the prefix)
|
||||
relative_path = key[len(prefix) :]
|
||||
|
||||
# Apply include filters
|
||||
if substrings_to_include:
|
||||
if not any(
|
||||
substr in relative_path for substr in substrings_to_include
|
||||
):
|
||||
continue
|
||||
|
||||
# Apply exclude filters
|
||||
if suffixes_to_exclude:
|
||||
if any(
|
||||
relative_path.endswith(suffix.lstrip("*"))
|
||||
for suffix in suffixes_to_exclude
|
||||
):
|
||||
continue
|
||||
|
||||
# Construct local file path
|
||||
local_file_path = os.path.join(path, relative_path)
|
||||
files_to_download.append((bucket, key, local_file_path))
|
||||
total_size += size
|
||||
|
||||
# Download files concurrently
|
||||
if not files_to_download:
|
||||
logger.info(f"No files matching filters to download from {bucket_uri}")
|
||||
return
|
||||
|
||||
# Dynamically calculate workers if not provided
|
||||
if max_workers is None:
|
||||
max_workers = S3FileSystem._calculate_optimal_workers(
|
||||
num_files=len(files_to_download),
|
||||
total_size=total_size,
|
||||
default_max=100,
|
||||
default_min=10,
|
||||
)
|
||||
|
||||
# Create shared client with proper connection pool size for downloads
|
||||
s3_client = S3FileSystem._get_s3_client(
|
||||
max_pool_connections=max_workers + 10, anonymous=is_anonymous
|
||||
)
|
||||
|
||||
failed_downloads = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Submit all download tasks with shared client
|
||||
future_to_key = {
|
||||
executor.submit(
|
||||
S3FileSystem._download_single_file,
|
||||
s3_client, # Pass shared client to each worker
|
||||
bucket,
|
||||
key,
|
||||
local_path,
|
||||
): key
|
||||
for bucket, key, local_path in files_to_download
|
||||
}
|
||||
|
||||
# Process completed downloads
|
||||
for future in as_completed(future_to_key):
|
||||
key, success = future.result()
|
||||
if not success:
|
||||
failed_downloads.append(key)
|
||||
|
||||
# Report any failures
|
||||
if failed_downloads:
|
||||
logger.error(
|
||||
f"Failed to download {len(failed_downloads)} files: {failed_downloads[:5]}..."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading files from {bucket_uri}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with `s3://`.
|
||||
"""
|
||||
try:
|
||||
bucket, prefix, is_anonymous = S3FileSystem._parse_s3_uri(bucket_uri)
|
||||
|
||||
# Ensure prefix has trailing slash for directory upload
|
||||
if prefix and not prefix.endswith("/"):
|
||||
prefix = f"{prefix}/"
|
||||
|
||||
s3_client = S3FileSystem._get_s3_client(anonymous=is_anonymous)
|
||||
|
||||
local_path_obj = Path(local_path)
|
||||
|
||||
# Walk through the local directory and upload each file
|
||||
if local_path_obj.is_file():
|
||||
# Upload a single file
|
||||
file_name = local_path_obj.name
|
||||
s3_key = f"{prefix}{file_name}" if prefix else file_name
|
||||
s3_client.upload_file(str(local_path_obj), bucket, s3_key)
|
||||
logger.debug(f"Uploaded {local_path_obj} to s3://{bucket}/{s3_key}")
|
||||
elif local_path_obj.is_dir():
|
||||
# Upload directory recursively
|
||||
for file_path in local_path_obj.rglob("*"):
|
||||
if file_path.is_file():
|
||||
# Calculate relative path from local_path
|
||||
relative_path = file_path.relative_to(local_path_obj)
|
||||
# Construct S3 key
|
||||
s3_key = f"{prefix}{relative_path.as_posix()}"
|
||||
# Upload file
|
||||
s3_client.upload_file(str(file_path), bucket, s3_key)
|
||||
logger.debug(f"Uploaded {file_path} to s3://{bucket}/{s3_key}")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Path {local_path} does not exist or is not a file/directory"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error uploading files to {bucket_uri}: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,546 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_filesystem import (
|
||||
AzureFileSystem,
|
||||
GCSFileSystem,
|
||||
PyArrowFileSystem,
|
||||
S3FileSystem,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def is_remote_path(path: str) -> bool:
|
||||
"""Check if the path is a remote path.
|
||||
|
||||
Args:
|
||||
path: The path to check.
|
||||
|
||||
Returns:
|
||||
True if the path is a remote path, False otherwise.
|
||||
"""
|
||||
return (
|
||||
path.startswith("s3://")
|
||||
or path.startswith("gs://")
|
||||
or path.startswith("abfss://")
|
||||
or path.startswith("azure://")
|
||||
or path.startswith("pyarrow-")
|
||||
)
|
||||
|
||||
|
||||
class ExtraFiles(BaseModelExtended):
|
||||
bucket_uri: str
|
||||
destination_path: str
|
||||
|
||||
|
||||
class CloudMirrorConfig(BaseModelExtended):
|
||||
"""Unified mirror config for cloud storage (S3, GCS, or Azure).
|
||||
|
||||
Args:
|
||||
bucket_uri: URI of the bucket (s3://, gs://, abfss://, or azure://)
|
||||
extra_files: Additional files to download
|
||||
"""
|
||||
|
||||
bucket_uri: Optional[str] = None
|
||||
extra_files: List[ExtraFiles] = Field(default_factory=list)
|
||||
|
||||
@field_validator("bucket_uri")
|
||||
@classmethod
|
||||
def check_uri_format(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
if not is_remote_path(value):
|
||||
raise ValueError(
|
||||
f'Got invalid value "{value}" for bucket_uri. '
|
||||
'Expected a URI that starts with "s3://", "gs://", "abfss://", or "azure://".'
|
||||
)
|
||||
return value
|
||||
|
||||
@property
|
||||
def storage_type(self) -> str:
|
||||
"""Returns the storage type ('s3', 'gcs', 'abfss', or 'azure') based on the URI prefix."""
|
||||
if self.bucket_uri is None:
|
||||
return None
|
||||
elif self.bucket_uri.startswith("s3://"):
|
||||
return "s3"
|
||||
elif self.bucket_uri.startswith("gs://"):
|
||||
return "gcs"
|
||||
elif self.bucket_uri.startswith("abfss://"):
|
||||
return "abfss"
|
||||
elif self.bucket_uri.startswith("azure://"):
|
||||
return "azure"
|
||||
return None
|
||||
|
||||
|
||||
class LoraMirrorConfig(BaseModelExtended):
|
||||
lora_model_id: str
|
||||
bucket_uri: str
|
||||
max_total_tokens: Optional[int]
|
||||
sync_args: Optional[List[str]] = None
|
||||
|
||||
@field_validator("bucket_uri")
|
||||
@classmethod
|
||||
def check_uri_format(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
if not is_remote_path(value):
|
||||
raise ValueError(
|
||||
f'Got invalid value "{value}" for bucket_uri. '
|
||||
'Expected a URI that starts with "s3://", "gs://", "abfss://", or "azure://".'
|
||||
)
|
||||
return value
|
||||
|
||||
@property
|
||||
def _bucket_name_and_path(self) -> str:
|
||||
for prefix in ["s3://", "gs://", "abfss://", "azure://"]:
|
||||
if self.bucket_uri.startswith(prefix):
|
||||
return self.bucket_uri[len(prefix) :]
|
||||
return self.bucket_uri
|
||||
|
||||
@property
|
||||
def bucket_name(self) -> str:
|
||||
bucket_part = self._bucket_name_and_path.split("/")[0]
|
||||
|
||||
# For ABFSS and Azure URIs, extract container name from container@account format
|
||||
if self.bucket_uri.startswith(("abfss://", "azure://")) and "@" in bucket_part:
|
||||
return bucket_part.split("@")[0]
|
||||
|
||||
return bucket_part
|
||||
|
||||
@property
|
||||
def bucket_path(self) -> str:
|
||||
return "/".join(self._bucket_name_and_path.split("/")[1:])
|
||||
|
||||
|
||||
class CloudFileSystem:
|
||||
"""A unified interface for cloud file system operations.
|
||||
|
||||
This class provides a simple interface for common operations on cloud storage
|
||||
systems (S3, GCS, Azure) by delegating to provider-specific implementations
|
||||
for optimal performance.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_fs(bucket_uri: str):
|
||||
"""Get the appropriate provider-specific filesystem class based on URI.
|
||||
|
||||
Args:
|
||||
bucket_uri: URI of the cloud storage (s3://, gs://, abfss://, or azure://)
|
||||
|
||||
Returns:
|
||||
The appropriate filesystem class (S3FileSystem, GCSFileSystem, or AzureFileSystem)
|
||||
|
||||
Raises:
|
||||
ValueError: If the URI scheme is not supported
|
||||
"""
|
||||
if bucket_uri.startswith("pyarrow-"):
|
||||
return PyArrowFileSystem
|
||||
elif bucket_uri.startswith("s3://"):
|
||||
return S3FileSystem
|
||||
elif bucket_uri.startswith("gs://"):
|
||||
return GCSFileSystem
|
||||
elif bucket_uri.startswith(("abfss://", "azure://")):
|
||||
return AzureFileSystem
|
||||
else:
|
||||
raise ValueError(f"Unsupported URI scheme: {bucket_uri}")
|
||||
|
||||
@staticmethod
|
||||
def get_file(
|
||||
object_uri: str, decode_as_utf_8: bool = True
|
||||
) -> Optional[Union[str, bytes]]:
|
||||
"""Download a file from cloud storage into memory.
|
||||
|
||||
Args:
|
||||
object_uri: URI of the file (s3://, gs://, abfss://, or azure://)
|
||||
decode_as_utf_8: If True, decode the file as UTF-8
|
||||
|
||||
Returns:
|
||||
File contents as string or bytes, or None if file doesn't exist
|
||||
"""
|
||||
fs_class = CloudFileSystem._get_provider_fs(object_uri)
|
||||
return fs_class.get_file(object_uri, decode_as_utf_8)
|
||||
|
||||
@staticmethod
|
||||
def list_subfolders(folder_uri: str) -> List[str]:
|
||||
"""List the immediate subfolders in a cloud directory.
|
||||
|
||||
Args:
|
||||
folder_uri: URI of the directory (s3://, gs://, abfss://, or azure://)
|
||||
|
||||
Returns:
|
||||
List of subfolder names (without trailing slashes)
|
||||
"""
|
||||
fs_class = CloudFileSystem._get_provider_fs(folder_uri)
|
||||
return fs_class.list_subfolders(folder_uri)
|
||||
|
||||
@staticmethod
|
||||
def download_files(
|
||||
path: str,
|
||||
bucket_uri: str,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
suffixes_to_exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Download files from cloud storage to a local directory.
|
||||
|
||||
Args:
|
||||
path: Local directory where files will be downloaded
|
||||
bucket_uri: URI of cloud directory
|
||||
substrings_to_include: Only include files containing these substrings
|
||||
suffixes_to_exclude: Exclude certain files from download (e.g .safetensors)
|
||||
"""
|
||||
fs_class = CloudFileSystem._get_provider_fs(bucket_uri)
|
||||
fs_class.download_files(
|
||||
path, bucket_uri, substrings_to_include, suffixes_to_exclude
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def download_model(
|
||||
destination_path: str,
|
||||
bucket_uri: str,
|
||||
tokenizer_only: bool,
|
||||
exclude_safetensors: bool = False,
|
||||
) -> None:
|
||||
"""Download a model from cloud storage.
|
||||
|
||||
This downloads a model in the format expected by the HuggingFace transformers
|
||||
library.
|
||||
|
||||
Args:
|
||||
destination_path: Path where the model will be stored
|
||||
bucket_uri: URI of the cloud directory containing the model
|
||||
tokenizer_only: If True, only download tokenizer-related files
|
||||
exclude_safetensors: If True, skip download of safetensor files
|
||||
"""
|
||||
try:
|
||||
# Get the provider-specific filesystem
|
||||
fs_class = CloudFileSystem._get_provider_fs(bucket_uri)
|
||||
|
||||
# Construct hash file URI
|
||||
hash_uri = bucket_uri.rstrip("/") + "/hash"
|
||||
|
||||
# Try to download and read hash file
|
||||
hash_content = fs_class.get_file(hash_uri, decode_as_utf_8=True)
|
||||
|
||||
if hash_content is not None:
|
||||
f_hash = hash_content.strip()
|
||||
logger.info(
|
||||
f"Detected hash file in bucket {bucket_uri}. "
|
||||
f"Using {f_hash} as the hash."
|
||||
)
|
||||
else:
|
||||
f_hash = "0000000000000000000000000000000000000000"
|
||||
logger.info(
|
||||
f"Hash file does not exist in bucket {bucket_uri}. "
|
||||
f"Using default hash {f_hash} - expected behavior - a hash file is optional. "
|
||||
)
|
||||
|
||||
# Write hash to refs/main
|
||||
main_dir = os.path.join(destination_path, "refs")
|
||||
os.makedirs(main_dir, exist_ok=True)
|
||||
with open(os.path.join(main_dir, "main"), "w") as f:
|
||||
f.write(f_hash)
|
||||
|
||||
# Create destination directory
|
||||
destination_dir = os.path.join(destination_path, "snapshots", f_hash)
|
||||
os.makedirs(destination_dir, exist_ok=True)
|
||||
|
||||
logger.info(f'Downloading model files to directory "{destination_dir}".')
|
||||
|
||||
# Download files
|
||||
tokenizer_file_substrings = (
|
||||
["tokenizer", "config.json", "chat_template"] if tokenizer_only else []
|
||||
)
|
||||
|
||||
safetensors_to_exclude = [".safetensors"] if exclude_safetensors else None
|
||||
|
||||
CloudFileSystem.download_files(
|
||||
path=destination_dir,
|
||||
bucket_uri=bucket_uri,
|
||||
substrings_to_include=tokenizer_file_substrings,
|
||||
suffixes_to_exclude=safetensors_to_exclude,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error downloading model from {bucket_uri}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def upload_files(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload files to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the files to upload.
|
||||
bucket_uri: The bucket uri to upload the files to, must start with
|
||||
`s3://`, `gs://`, `abfss://`, or `azure://`.
|
||||
"""
|
||||
fs_class = CloudFileSystem._get_provider_fs(bucket_uri)
|
||||
fs_class.upload_files(local_path, bucket_uri)
|
||||
|
||||
@staticmethod
|
||||
def upload_model(
|
||||
local_path: str,
|
||||
bucket_uri: str,
|
||||
) -> None:
|
||||
"""Upload a model to cloud storage.
|
||||
|
||||
Args:
|
||||
local_path: The local path of the model.
|
||||
bucket_uri: The bucket uri to upload the model to, must start with `s3://` or `gs://`.
|
||||
"""
|
||||
try:
|
||||
# If refs/main exists, upload as hash, and treat snapshots/<hash> as the model.
|
||||
# Otherwise, this is a custom model, we do not assume folder hierarchy.
|
||||
refs_main = Path(local_path, "refs", "main")
|
||||
if refs_main.exists():
|
||||
model_path = os.path.join(
|
||||
local_path, "snapshots", refs_main.read_text().strip()
|
||||
)
|
||||
CloudFileSystem.upload_files(
|
||||
local_path=model_path, bucket_uri=bucket_uri
|
||||
)
|
||||
CloudFileSystem.upload_files(
|
||||
local_path=str(refs_main),
|
||||
bucket_uri=os.path.join(bucket_uri, "hash"),
|
||||
)
|
||||
else:
|
||||
CloudFileSystem.upload_files(
|
||||
local_path=local_path, bucket_uri=bucket_uri
|
||||
)
|
||||
logger.info(f"Uploaded model files to {bucket_uri}.")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error uploading model to {bucket_uri}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class _CacheEntry(NamedTuple):
|
||||
value: Any
|
||||
expire_time: Optional[float]
|
||||
|
||||
|
||||
class CloudObjectCache:
|
||||
"""A cache that works with both sync and async fetch functions.
|
||||
|
||||
The purpose of this data structure is to cache the result of a function call
|
||||
usually used to fetch a value from a cloud object store.
|
||||
|
||||
The idea is this:
|
||||
- Cloud operations are expensive
|
||||
- In LoRA specifically, we would fetch remote storage to download the model weights
|
||||
at each request.
|
||||
- If the same model is requested many times, we don't want to inflate the time to first token.
|
||||
- We control the cache via not only the least recently used eviction policy, but also
|
||||
by expiring cache entries after a certain time.
|
||||
- If the object is missing, we cache the missing status for a small duration while if
|
||||
the object exists, we cache the object for a longer duration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_size: int,
|
||||
fetch_fn: Union[Callable[[str], Any], Callable[[str], Awaitable[Any]]],
|
||||
missing_expire_seconds: Optional[int] = None,
|
||||
exists_expire_seconds: Optional[int] = None,
|
||||
missing_object_value: Any = object(),
|
||||
):
|
||||
"""Initialize the cache.
|
||||
|
||||
Args:
|
||||
max_size: Maximum number of items to store in cache
|
||||
fetch_fn: Function to fetch values (can be sync or async)
|
||||
missing_expire_seconds: How long to cache missing objects (None for no expiration)
|
||||
exists_expire_seconds: How long to cache existing objects (None for no expiration)
|
||||
missing_object_value: Sentinel value used to represent a missing object in the cache.
|
||||
"""
|
||||
self._cache: Dict[str, _CacheEntry] = {}
|
||||
self._max_size = max_size
|
||||
self._fetch_fn = fetch_fn
|
||||
self._missing_expire_seconds = missing_expire_seconds
|
||||
self._exists_expire_seconds = exists_expire_seconds
|
||||
self._is_async = inspect.iscoroutinefunction(fetch_fn) or (
|
||||
callable(fetch_fn) and inspect.iscoroutinefunction(fetch_fn.__call__)
|
||||
)
|
||||
self._missing_object_value = missing_object_value
|
||||
# Lock for thread-safe cache access
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def aget(self, key: str) -> Any:
|
||||
"""Async get value from cache or fetch it if needed."""
|
||||
|
||||
if not self._is_async:
|
||||
raise ValueError("Cannot use async get() with sync fetch function")
|
||||
|
||||
async with self._lock:
|
||||
value, should_fetch = self._check_cache(key)
|
||||
if not should_fetch:
|
||||
return value
|
||||
|
||||
# Fetch new value
|
||||
value = await self._fetch_fn(key)
|
||||
self._update_cache(key, value)
|
||||
return value
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Sync get value from cache or fetch it if needed."""
|
||||
if self._is_async:
|
||||
raise ValueError("Cannot use sync get() with async fetch function")
|
||||
|
||||
# For sync access, we use a simple check-then-act pattern
|
||||
# This is safe because sync functions are not used in async context
|
||||
value, should_fetch = self._check_cache(key)
|
||||
if not should_fetch:
|
||||
return value
|
||||
|
||||
# Fetch new value
|
||||
value = self._fetch_fn(key)
|
||||
self._update_cache(key, value)
|
||||
return value
|
||||
|
||||
def _check_cache(self, key: str) -> tuple[Any, bool]:
|
||||
"""Check if key exists in cache and is valid.
|
||||
|
||||
Args:
|
||||
key: The cache key to check.
|
||||
|
||||
Returns:
|
||||
Tuple of (value, should_fetch)
|
||||
where should_fetch is True if we need to fetch a new value
|
||||
"""
|
||||
now = time.monotonic()
|
||||
|
||||
if key in self._cache:
|
||||
value, expire_time = self._cache[key]
|
||||
if expire_time is None or now < expire_time:
|
||||
return value, False
|
||||
|
||||
return None, True
|
||||
|
||||
def _update_cache(self, key: str, value: Any) -> None:
|
||||
"""Update cache with new value."""
|
||||
now = time.monotonic()
|
||||
|
||||
# Calculate expiration
|
||||
expire_time = None
|
||||
if (
|
||||
self._missing_expire_seconds is not None
|
||||
or self._exists_expire_seconds is not None
|
||||
):
|
||||
if value is self._missing_object_value:
|
||||
expire_time = (
|
||||
now + self._missing_expire_seconds
|
||||
if self._missing_expire_seconds
|
||||
else None
|
||||
)
|
||||
else:
|
||||
expire_time = (
|
||||
now + self._exists_expire_seconds
|
||||
if self._exists_expire_seconds
|
||||
else None
|
||||
)
|
||||
|
||||
# Enforce size limit by removing oldest entry if needed
|
||||
# This is an O(n) operation but it's fine since the cache size is usually small.
|
||||
if len(self._cache) >= self._max_size:
|
||||
oldest_key = min(
|
||||
self._cache, key=lambda k: self._cache[k].expire_time or float("inf")
|
||||
)
|
||||
del self._cache[oldest_key]
|
||||
|
||||
self._cache[key] = _CacheEntry(value, expire_time)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
class CloudModelAccessor:
|
||||
"""Unified accessor for models stored in cloud storage (S3 or GCS).
|
||||
|
||||
Args:
|
||||
model_id: The model id to download or upload.
|
||||
mirror_config: The mirror config for the model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_id: str, mirror_config: CloudMirrorConfig):
|
||||
self.model_id = model_id
|
||||
self.mirror_config = mirror_config
|
||||
|
||||
def _get_lock_path(self, suffix: str = "") -> Path:
|
||||
return Path(
|
||||
"~", f"{self.model_id.replace('/', '--')}{suffix}.lock"
|
||||
).expanduser()
|
||||
|
||||
def _get_model_path(self) -> Path:
|
||||
if Path(self.model_id).exists():
|
||||
return Path(self.model_id)
|
||||
# Delayed import to avoid circular dependencies
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
return Path(
|
||||
HF_HUB_CACHE, f"models--{self.model_id.replace('/', '--')}"
|
||||
).expanduser()
|
||||
|
||||
|
||||
def remote_object_cache(
|
||||
max_size: int,
|
||||
missing_expire_seconds: Optional[int] = None,
|
||||
exists_expire_seconds: Optional[int] = None,
|
||||
missing_object_value: Any = None,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""A decorator that provides async caching using CloudObjectCache.
|
||||
|
||||
This is a direct replacement for the remote_object_cache/cachetools combination,
|
||||
using CloudObjectCache internally to maintain cache state.
|
||||
|
||||
Args:
|
||||
max_size: Maximum number of items to store in cache
|
||||
missing_expire_seconds: How long to cache missing objects
|
||||
exists_expire_seconds: How long to cache existing objects
|
||||
missing_object_value: Value to use for missing objects
|
||||
|
||||
Returns:
|
||||
A decorator that wraps an async function with cache lookup.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
# Create a single cache instance for this function
|
||||
cache = CloudObjectCache(
|
||||
max_size=max_size,
|
||||
fetch_fn=func,
|
||||
missing_expire_seconds=missing_expire_seconds,
|
||||
exists_expire_seconds=exists_expire_seconds,
|
||||
missing_object_value=missing_object_value,
|
||||
)
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract the key from either first positional arg or object_uri kwarg
|
||||
key = args[0] if args else kwargs.get("object_uri")
|
||||
return await cache.aget(key)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,324 @@
|
||||
import enum
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
from ray.llm._internal.common.callbacks.base import CallbackBase
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_utils import (
|
||||
CloudFileSystem,
|
||||
CloudMirrorConfig,
|
||||
CloudModelAccessor,
|
||||
is_remote_path,
|
||||
)
|
||||
from ray.llm._internal.common.utils.import_utils import try_import
|
||||
|
||||
torch = try_import("torch")
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
STREAMING_LOAD_FORMATS = ["runai_streamer", "runai_streamer_sharded", "tensorizer"]
|
||||
|
||||
|
||||
class NodeModelDownloadable(enum.Enum):
|
||||
"""Defines which files to download from cloud storage."""
|
||||
|
||||
MODEL_AND_TOKENIZER = enum.auto()
|
||||
TOKENIZER_ONLY = enum.auto()
|
||||
EXCLUDE_SAFETENSORS = enum.auto()
|
||||
NONE = enum.auto()
|
||||
|
||||
def __bool__(self):
|
||||
return self != NodeModelDownloadable.NONE
|
||||
|
||||
def union(self, other: "NodeModelDownloadable") -> "NodeModelDownloadable":
|
||||
"""Return a NodeModelDownloadable that is a union of this and the other."""
|
||||
if (
|
||||
self == NodeModelDownloadable.MODEL_AND_TOKENIZER
|
||||
or other == NodeModelDownloadable.MODEL_AND_TOKENIZER
|
||||
):
|
||||
return NodeModelDownloadable.MODEL_AND_TOKENIZER
|
||||
if (
|
||||
self == NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
or other == NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
):
|
||||
return NodeModelDownloadable.EXCLUDE_SAFETENSORS
|
||||
if (
|
||||
self == NodeModelDownloadable.TOKENIZER_ONLY
|
||||
or other == NodeModelDownloadable.TOKENIZER_ONLY
|
||||
):
|
||||
return NodeModelDownloadable.TOKENIZER_ONLY
|
||||
|
||||
return NodeModelDownloadable.NONE
|
||||
|
||||
|
||||
def get_model_entrypoint(model_id: str) -> str:
|
||||
"""Get the path to entrypoint of the model on disk if it exists, otherwise return the model id as is.
|
||||
|
||||
Entrypoint is typically <HF_HUB_CACHE>/models--<model_id>/
|
||||
|
||||
Args:
|
||||
model_id: Hugging Face model ID.
|
||||
|
||||
Returns:
|
||||
The path to the entrypoint of the model on disk if it exists, otherwise the model id as is.
|
||||
"""
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
model_dir = Path(
|
||||
HF_HUB_CACHE, f"models--{model_id.replace('/', '--')}"
|
||||
).expanduser()
|
||||
if not model_dir.exists():
|
||||
return model_id
|
||||
return str(model_dir.absolute())
|
||||
|
||||
|
||||
def get_model_location_on_disk(model_id: str) -> str:
|
||||
"""Get the location of the model on disk if exists, otherwise return the model id as is.
|
||||
|
||||
Args:
|
||||
model_id: Hugging Face model ID.
|
||||
|
||||
Returns:
|
||||
The path to the model on disk if it exists, otherwise the model id as is.
|
||||
"""
|
||||
model_dir = Path(get_model_entrypoint(model_id))
|
||||
model_id_or_path = model_id
|
||||
|
||||
model_dir_refs_main = Path(model_dir, "refs", "main")
|
||||
|
||||
if model_dir.exists():
|
||||
if model_dir_refs_main.exists():
|
||||
# If refs/main exists, use the snapshot hash to find the model
|
||||
# and check if *config.json (could be config.json for general models
|
||||
# or adapter_config.json for LoRA adapters) exists to make sure it
|
||||
# follows HF model repo structure.
|
||||
with open(model_dir_refs_main, "r") as f:
|
||||
snapshot_hash = f.read().strip()
|
||||
|
||||
snapshot_hash_path = Path(model_dir, "snapshots", snapshot_hash)
|
||||
if snapshot_hash_path.exists() and list(
|
||||
Path(snapshot_hash_path).glob("*config.json")
|
||||
):
|
||||
model_id_or_path = str(snapshot_hash_path.absolute())
|
||||
else:
|
||||
# If it doesn't have refs/main, it is a custom model repo
|
||||
# and we can just return the model_dir.
|
||||
model_id_or_path = str(model_dir.absolute())
|
||||
|
||||
return model_id_or_path
|
||||
|
||||
|
||||
class CloudModelDownloader(CloudModelAccessor):
|
||||
"""Unified downloader for models stored in cloud storage (S3 or GCS).
|
||||
|
||||
Args:
|
||||
model_id: The model id to download.
|
||||
mirror_config: The mirror config for the model.
|
||||
"""
|
||||
|
||||
def get_model(
|
||||
self,
|
||||
tokenizer_only: bool,
|
||||
exclude_safetensors: bool = False,
|
||||
) -> str:
|
||||
"""Gets a model from cloud storage and stores it locally.
|
||||
|
||||
Args:
|
||||
tokenizer_only: whether to download only the tokenizer files.
|
||||
exclude_safetensors: whether to download safetensors files to disk.
|
||||
|
||||
Returns:
|
||||
File path of model if downloaded, else the model id.
|
||||
"""
|
||||
bucket_uri = self.mirror_config.bucket_uri
|
||||
|
||||
if bucket_uri is None:
|
||||
return self.model_id
|
||||
|
||||
# Use different lock paths for different download types to avoid race conditions
|
||||
# where a tokenizer-only download completes and subsequent full model downloads
|
||||
# incorrectly assume the model weights are already cached.
|
||||
if tokenizer_only:
|
||||
lock_suffix = "-tokenizer"
|
||||
elif exclude_safetensors:
|
||||
lock_suffix = "-exclude-safetensors"
|
||||
else:
|
||||
lock_suffix = "-full"
|
||||
lock_path = self._get_lock_path(suffix=lock_suffix)
|
||||
path = self._get_model_path()
|
||||
storage_type = self.mirror_config.storage_type
|
||||
|
||||
try:
|
||||
# Timeout 0 means there will be only one attempt to acquire
|
||||
# the file lock. If it cannot be acquired, a TimeoutError
|
||||
# will be thrown.
|
||||
# This ensures that subsequent processes don't duplicate work.
|
||||
with FileLock(lock_path, timeout=0):
|
||||
try:
|
||||
if exclude_safetensors:
|
||||
logger.info("Skipping download of safetensors files.")
|
||||
CloudFileSystem.download_model(
|
||||
destination_path=path,
|
||||
bucket_uri=bucket_uri,
|
||||
tokenizer_only=tokenizer_only,
|
||||
exclude_safetensors=exclude_safetensors,
|
||||
)
|
||||
logger.info(
|
||||
"Finished downloading %s for %s from %s storage",
|
||||
"tokenizer" if tokenizer_only else "model and tokenizer",
|
||||
self.model_id,
|
||||
storage_type.upper() if storage_type else "cloud",
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception(
|
||||
"Failed to download files for model %s from %s storage",
|
||||
self.model_id,
|
||||
storage_type.upper() if storage_type else "cloud",
|
||||
)
|
||||
except TimeoutError:
|
||||
# If the directory is already locked, then wait but do not do anything.
|
||||
with FileLock(lock_path, timeout=-1):
|
||||
pass
|
||||
return get_model_location_on_disk(self.model_id)
|
||||
|
||||
def get_extra_files(self) -> List[str]:
|
||||
"""Gets user-specified extra files from cloud storage and stores them in
|
||||
provided paths.
|
||||
|
||||
Returns: list of file paths of extra files if downloaded.
|
||||
"""
|
||||
paths = []
|
||||
extra_files = self.mirror_config.extra_files or []
|
||||
if not extra_files:
|
||||
return paths
|
||||
|
||||
lock_path = self._get_lock_path(suffix="-extra_files")
|
||||
storage_type = self.mirror_config.storage_type
|
||||
|
||||
logger.info(
|
||||
f"Downloading extra files for {self.model_id} from {storage_type} storage"
|
||||
)
|
||||
try:
|
||||
# Timeout 0 means there will be only one attempt to acquire
|
||||
# the file lock. If it cannot be acquired, a TimeoutError
|
||||
# will be thrown.
|
||||
# This ensures that subsequent processes don't duplicate work.
|
||||
with FileLock(lock_path, timeout=0):
|
||||
for extra_file in extra_files:
|
||||
path = Path(
|
||||
os.path.expandvars(extra_file.destination_path)
|
||||
).expanduser()
|
||||
paths.append(path)
|
||||
CloudFileSystem.download_files(
|
||||
path=path,
|
||||
bucket_uri=extra_file.bucket_uri,
|
||||
)
|
||||
except TimeoutError:
|
||||
# If the directory is already locked, then wait but do not do anything.
|
||||
with FileLock(lock_path, timeout=-1):
|
||||
pass
|
||||
return paths
|
||||
|
||||
|
||||
def _log_download_info(
|
||||
*, source: str, download_model: NodeModelDownloadable, download_extra_files: bool
|
||||
):
|
||||
if download_model == NodeModelDownloadable.NONE:
|
||||
if download_extra_files:
|
||||
logger.info("Downloading extra files from %s", source)
|
||||
else:
|
||||
logger.info("Not downloading anything from %s", source)
|
||||
elif download_model == NodeModelDownloadable.TOKENIZER_ONLY:
|
||||
if download_extra_files:
|
||||
logger.info("Downloading tokenizer and extra files from %s", source)
|
||||
else:
|
||||
logger.info("Downloading tokenizer from %s", source)
|
||||
elif download_model == NodeModelDownloadable.MODEL_AND_TOKENIZER:
|
||||
if download_extra_files:
|
||||
logger.info("Downloading model, tokenizer, and extra files from %s", source)
|
||||
else:
|
||||
logger.info("Downloading model and tokenizer from %s", source)
|
||||
|
||||
|
||||
def download_model_files(
|
||||
model_id: Optional[str] = None,
|
||||
mirror_config: Optional[CloudMirrorConfig] = None,
|
||||
download_model: NodeModelDownloadable = NodeModelDownloadable.MODEL_AND_TOKENIZER,
|
||||
download_extra_files: bool = True,
|
||||
callback: Optional[CallbackBase] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Download the model files from the cloud storage. We support two ways to specify
|
||||
the remote model path in the cloud storage:
|
||||
Approach 1:
|
||||
- model_id: The vanilla model id such as "meta-llama/Llama-3.1-8B-Instruct".
|
||||
- mirror_config: Config for downloading model from cloud storage.
|
||||
|
||||
Approach 2:
|
||||
- model_id: The remote path (s3:// or gs://) in the cloud storage.
|
||||
- mirror_config: None.
|
||||
In this approach, we will create a CloudMirrorConfig from the model_id and use that
|
||||
to download the model.
|
||||
|
||||
Args:
|
||||
model_id: The model id.
|
||||
mirror_config: Config for downloading model from cloud storage.
|
||||
download_model: What parts of the model to download.
|
||||
download_extra_files: Whether to download extra files specified in the mirror config.
|
||||
callback: Callback to run before downloading model files.
|
||||
|
||||
Returns:
|
||||
The local path to the downloaded model, or the original model ID
|
||||
if no cloud storage mirror is configured or if the model is not downloaded.
|
||||
"""
|
||||
|
||||
# Create the torch cache kernels directory if it doesn't exist.
|
||||
# This is a workaround for a torch issue, where the kernels directory
|
||||
# cannot be created by torch if the parent directory doesn't exist.
|
||||
torch_cache_home = torch.hub._get_torch_home()
|
||||
os.makedirs(os.path.join(torch_cache_home, "kernels"), exist_ok=True)
|
||||
model_path_or_id = model_id
|
||||
|
||||
if callback is not None:
|
||||
callback.run_callback_sync("on_before_download_model_files_distributed")
|
||||
|
||||
if model_id is None:
|
||||
return None
|
||||
|
||||
if mirror_config is None:
|
||||
if is_remote_path(model_id):
|
||||
logger.info(
|
||||
"Creating a CloudMirrorConfig from remote model path %s", model_id
|
||||
)
|
||||
mirror_config = CloudMirrorConfig(bucket_uri=model_id)
|
||||
else:
|
||||
logger.info("No cloud storage mirror configured")
|
||||
return model_id
|
||||
|
||||
storage_type = mirror_config.storage_type
|
||||
source = (
|
||||
f"{storage_type.upper()} mirror" if storage_type else "Cloud storage mirror"
|
||||
)
|
||||
|
||||
_log_download_info(
|
||||
source=source,
|
||||
download_model=download_model,
|
||||
download_extra_files=download_extra_files,
|
||||
)
|
||||
|
||||
downloader = CloudModelDownloader(model_id, mirror_config)
|
||||
|
||||
if download_model != NodeModelDownloadable.NONE:
|
||||
model_path_or_id = downloader.get_model(
|
||||
tokenizer_only=download_model == NodeModelDownloadable.TOKENIZER_ONLY,
|
||||
exclude_safetensors=download_model
|
||||
== NodeModelDownloadable.EXCLUDE_SAFETENSORS,
|
||||
)
|
||||
|
||||
if download_extra_files:
|
||||
downloader.get_extra_files()
|
||||
|
||||
return model_path_or_id
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Utility functions for importing modules in the LLM module."""
|
||||
import importlib
|
||||
import logging
|
||||
from types import ModuleType
|
||||
from typing import Any, NoReturn, Optional, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def try_import(name: str, error: bool = False) -> Optional[ModuleType]:
|
||||
"""Try importing the module and returns the module (or None).
|
||||
|
||||
Args:
|
||||
name: The name of the module to import.
|
||||
error: Whether to raise an error if the module cannot be imported.
|
||||
|
||||
Returns:
|
||||
The module, or None if it cannot be imported.
|
||||
|
||||
Raises:
|
||||
ImportError: If error=True and the module is not installed.
|
||||
"""
|
||||
try:
|
||||
return importlib.import_module(name)
|
||||
except ImportError:
|
||||
if error:
|
||||
raise ImportError(f"Could not import {name}")
|
||||
else:
|
||||
logger.warning("Could not import %s", name)
|
||||
return None
|
||||
|
||||
|
||||
def raise_llm_engine_import_error(
|
||||
vllm_error: ImportError,
|
||||
sglang_error: ImportError,
|
||||
) -> NoReturn:
|
||||
"""Raise a descriptive ImportError when both vLLM and SGLang fail to import.
|
||||
|
||||
Distinguishes between a package not being installed (ModuleNotFoundError
|
||||
whose .name matches the top-level package) and a broken installation
|
||||
(any other ImportError, e.g. a missing .so or a missing transitive dep).
|
||||
|
||||
Args:
|
||||
vllm_error: The ImportError raised when importing vLLM.
|
||||
sglang_error: The ImportError raised when importing SGLang.
|
||||
"""
|
||||
vllm_not_installed = (
|
||||
isinstance(vllm_error, ModuleNotFoundError) and vllm_error.name == "vllm"
|
||||
)
|
||||
sglang_not_installed = (
|
||||
isinstance(sglang_error, ModuleNotFoundError) and sglang_error.name == "sglang"
|
||||
)
|
||||
|
||||
if vllm_not_installed and sglang_not_installed:
|
||||
raise ImportError(
|
||||
"Neither vLLM nor SGLang is installed. At least one is required "
|
||||
"for Ray Serve LLM protocol models. Install with: "
|
||||
"`pip install ray[llm]` or `pip install sglang[all]`"
|
||||
)
|
||||
|
||||
messages = []
|
||||
if not vllm_not_installed:
|
||||
messages.append(
|
||||
"vLLM is installed but failed to import. This may indicate a "
|
||||
"CUDA version mismatch or a missing vLLM dependency. "
|
||||
f"Original error: {vllm_error}"
|
||||
)
|
||||
if not sglang_not_installed:
|
||||
messages.append(
|
||||
"SGLang is installed but failed to import. This may indicate a "
|
||||
"missing SGLang dependency. "
|
||||
f"Original error: {sglang_error}"
|
||||
)
|
||||
# Chain to the error that is actually relevant: vLLM's if it is broken,
|
||||
# otherwise sglang's (i.e. vLLM was simply not installed).
|
||||
cause = vllm_error if not vllm_not_installed else sglang_error
|
||||
raise ImportError("\n".join(messages)) from cause
|
||||
|
||||
|
||||
def load_class(path: str) -> Type[Any]:
|
||||
"""Load class from string path."""
|
||||
if ":" in path:
|
||||
module_path, class_name = path.rsplit(":", 1)
|
||||
else:
|
||||
module_path, class_name = path.rsplit(".", 1)
|
||||
|
||||
module = try_import(module_path, error=True)
|
||||
callback_class = getattr(module, class_name)
|
||||
|
||||
return callback_class
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Generic LoRA utilities and abstractions.
|
||||
|
||||
This module provides canonical LoRA utility functions for both serve and batch components.
|
||||
It serves as the single source of truth for LoRA operations and builds on the generic
|
||||
download primitives from download_utils.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, List, Optional, TypeVar, Union
|
||||
|
||||
from ray.llm._internal.common.constants import (
|
||||
CLOUD_OBJECT_EXISTS_EXPIRE_S,
|
||||
CLOUD_OBJECT_MISSING_EXPIRE_S,
|
||||
LORA_ADAPTER_CONFIG_NAME,
|
||||
)
|
||||
|
||||
# Import the global ID manager from common models
|
||||
from ray.llm._internal.common.models import make_async
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_utils import (
|
||||
CloudFileSystem,
|
||||
is_remote_path,
|
||||
remote_object_cache,
|
||||
)
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
CloudMirrorConfig,
|
||||
CloudModelDownloader,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Sentinel object for missing cloud objects
|
||||
CLOUD_OBJECT_MISSING = object()
|
||||
|
||||
DEFAULT_LORA_MAX_TOTAL_TOKENS = 4096
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_base_model_id(model_id: str) -> str:
|
||||
"""Get base model id for a given model id."""
|
||||
return model_id.split(":")[0]
|
||||
|
||||
|
||||
def get_lora_id(lora_model_id: str) -> str:
|
||||
"""Get lora id for a given lora model id."""
|
||||
return ":".join(lora_model_id.split(":")[1:])
|
||||
|
||||
|
||||
def clean_model_id(model_id: str) -> str:
|
||||
"""Clean model ID for filesystem usage by replacing slashes with dashes."""
|
||||
return model_id.replace("/", "--")
|
||||
|
||||
|
||||
def clear_directory(dir: str) -> None:
|
||||
"""Clear a directory recursively, ignoring missing directories."""
|
||||
try:
|
||||
subprocess.run(f"rm -r {dir}", shell=True, check=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def retry_with_exponential_backoff(
|
||||
max_tries: int,
|
||||
exception_to_check: type[Exception],
|
||||
base_delay: float = 1,
|
||||
max_delay: float = 32,
|
||||
exponential_base: float = 2,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""Retry decorator with exponential backoff."""
|
||||
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||
delay = base_delay
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_tries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exception_to_check as e:
|
||||
last_exception = e
|
||||
if attempt == max_tries - 1: # Last attempt
|
||||
raise last_exception
|
||||
|
||||
# Log the failure and retry
|
||||
logger.warning(
|
||||
f"Attempt {attempt + 1}/{max_tries} failed: {str(e)}. "
|
||||
f"Retrying in {delay} seconds..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
# Calculate next delay with exponential backoff
|
||||
delay = min(delay * exponential_base, max_delay)
|
||||
|
||||
# This should never be reached due to the raise in the loop
|
||||
raise last_exception if last_exception else RuntimeError(
|
||||
"Unexpected error in retry logic"
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def sync_files_with_lock(
|
||||
bucket_uri: str,
|
||||
local_path: str,
|
||||
timeout: Optional[float] = None,
|
||||
substrings_to_include: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Sync files from bucket_uri to local_path with file locking."""
|
||||
from filelock import FileLock
|
||||
|
||||
logger.info("Downloading %s to %s", bucket_uri, local_path)
|
||||
|
||||
with FileLock(local_path + ".lock", timeout=timeout or -1):
|
||||
try:
|
||||
CloudFileSystem.download_files(
|
||||
path=local_path,
|
||||
bucket_uri=bucket_uri,
|
||||
substrings_to_include=substrings_to_include,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to sync files from %s to %s: %s",
|
||||
bucket_uri,
|
||||
local_path,
|
||||
str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@make_async
|
||||
def _get_object_from_cloud(object_uri: str) -> Union[str, object]:
|
||||
"""Gets an object from the cloud."""
|
||||
if object_uri.endswith("/"):
|
||||
raise ValueError(f'object_uri {object_uri} must not end with a "/".')
|
||||
|
||||
body_str = CloudFileSystem.get_file(object_uri)
|
||||
|
||||
if body_str is None:
|
||||
logger.info(f"{object_uri} does not exist.")
|
||||
return CLOUD_OBJECT_MISSING
|
||||
else:
|
||||
return body_str
|
||||
|
||||
|
||||
@remote_object_cache(
|
||||
max_size=4096,
|
||||
missing_expire_seconds=CLOUD_OBJECT_MISSING_EXPIRE_S,
|
||||
exists_expire_seconds=CLOUD_OBJECT_EXISTS_EXPIRE_S,
|
||||
missing_object_value=CLOUD_OBJECT_MISSING,
|
||||
)
|
||||
async def get_object_from_cloud(object_uri: str) -> Union[str, object]:
|
||||
"""Gets an object from the cloud with caching."""
|
||||
return await _get_object_from_cloud(object_uri)
|
||||
|
||||
|
||||
async def get_lora_finetuned_context_length(bucket_uri: str) -> Optional[int]:
|
||||
"""Gets the sequence length used to tune the LoRA adapter."""
|
||||
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:
|
||||
logger.debug(f"LoRA adapter config file not found at {object_uri}")
|
||||
return None
|
||||
|
||||
try:
|
||||
adapter_config_str = object_str_or_missing_message
|
||||
adapter_config = json.loads(adapter_config_str)
|
||||
return adapter_config.get("max_length")
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logger.warning(f"Failed to parse LoRA adapter config at {object_uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_lora_model_ids(
|
||||
dynamic_lora_loading_path: str,
|
||||
base_model_id: str,
|
||||
) -> List[str]:
|
||||
"""Get the model IDs of all the LoRA models.
|
||||
|
||||
The dynamic_lora_loading_path is expected to hold subfolders each for
|
||||
a different lora checkpoint. Each subfolder name will correspond to
|
||||
the unique identifier for the lora checkpoint. The lora model is
|
||||
accessible via <base_model_id>:<lora_id>. Therefore, we prepend
|
||||
the base_model_id to each subfolder name.
|
||||
|
||||
Args:
|
||||
dynamic_lora_loading_path: the cloud folder that contains all the LoRA
|
||||
weights.
|
||||
base_model_id: model ID of the base model.
|
||||
|
||||
Returns:
|
||||
List of LoRA fine-tuned model IDs. Does not include the base model
|
||||
itself.
|
||||
"""
|
||||
lora_subfolders = CloudFileSystem.list_subfolders(dynamic_lora_loading_path)
|
||||
|
||||
lora_model_ids = []
|
||||
for subfolder in lora_subfolders:
|
||||
lora_model_ids.append(f"{base_model_id}:{subfolder}")
|
||||
|
||||
return lora_model_ids
|
||||
|
||||
|
||||
def download_lora_adapter(
|
||||
lora_name: str,
|
||||
remote_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Download a LoRA adapter from remote storage.
|
||||
|
||||
This maintains backward compatibility with existing code.
|
||||
"""
|
||||
|
||||
assert not is_remote_path(
|
||||
lora_name
|
||||
), "lora_name cannot be a remote path (s3:// or gs://)"
|
||||
|
||||
if remote_path is None:
|
||||
return lora_name
|
||||
|
||||
lora_path = os.path.join(remote_path, lora_name)
|
||||
mirror_config = CloudMirrorConfig(bucket_uri=lora_path)
|
||||
downloader = CloudModelDownloader(lora_name, mirror_config)
|
||||
return downloader.get_model(tokenizer_only=False)
|
||||
@@ -0,0 +1,126 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from filelock import FileLock
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from ray.llm._internal.common.observability.logging import get_logger
|
||||
from ray.llm._internal.common.utils.cloud_utils import (
|
||||
CloudFileSystem,
|
||||
CloudMirrorConfig,
|
||||
CloudModelAccessor,
|
||||
is_remote_path,
|
||||
)
|
||||
from ray.llm._internal.common.utils.download_utils import (
|
||||
get_model_entrypoint,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CloudModelUploader(CloudModelAccessor):
|
||||
"""Unified uploader to upload models to cloud storage (S3 or GCS).
|
||||
|
||||
Args:
|
||||
model_id: The model id to upload.
|
||||
mirror_config: The mirror config for the model.
|
||||
"""
|
||||
|
||||
def upload_model(self) -> str:
|
||||
"""Upload the model to cloud storage (s3 or gcs).
|
||||
|
||||
Returns:
|
||||
The remote path of the uploaded model.
|
||||
"""
|
||||
bucket_uri = self.mirror_config.bucket_uri
|
||||
|
||||
lock_path = self._get_lock_path()
|
||||
path = self._get_model_path()
|
||||
storage_type = self.mirror_config.storage_type
|
||||
|
||||
try:
|
||||
# Timeout 0 means there will be only one attempt to acquire
|
||||
# the file lock. If it cannot be acquired, a TimeoutError
|
||||
# will be thrown.
|
||||
# This ensures that subsequent processes don't duplicate work.
|
||||
with FileLock(lock_path, timeout=0):
|
||||
try:
|
||||
CloudFileSystem.upload_model(
|
||||
local_path=path,
|
||||
bucket_uri=bucket_uri,
|
||||
)
|
||||
logger.info(
|
||||
"Finished uploading %s to %s storage",
|
||||
self.model_id,
|
||||
storage_type.upper() if storage_type else "cloud",
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception(
|
||||
"Failed to upload model %s to %s storage",
|
||||
self.model_id,
|
||||
storage_type.upper() if storage_type else "cloud",
|
||||
)
|
||||
except TimeoutError:
|
||||
# If the directory is already locked, then wait but do not do anything.
|
||||
with FileLock(lock_path, timeout=-1):
|
||||
pass
|
||||
return bucket_uri
|
||||
|
||||
|
||||
def upload_model_files(model_id: str, bucket_uri: str) -> str:
|
||||
"""Upload the model files to cloud storage (s3 or gcs).
|
||||
|
||||
If `model_id` is a local path, the files will be uploaded to the cloud storage.
|
||||
If `model_id` is a huggingface model id, the model will be downloaded from huggingface
|
||||
and then uploaded to the cloud storage.
|
||||
|
||||
Args:
|
||||
model_id: The huggingface model id, or local model path to upload.
|
||||
bucket_uri: The bucket uri to upload the model to, must start with `s3://` or `gs://`.
|
||||
|
||||
Returns:
|
||||
The remote path of the uploaded model.
|
||||
"""
|
||||
assert not is_remote_path(
|
||||
model_id
|
||||
), f"model_id must NOT be a remote path: {model_id}"
|
||||
assert is_remote_path(bucket_uri), f"bucket_uri must be a remote path: {bucket_uri}"
|
||||
|
||||
if not Path(model_id).exists():
|
||||
maybe_downloaded_model_path = get_model_entrypoint(model_id)
|
||||
if not Path(maybe_downloaded_model_path).exists():
|
||||
logger.info(
|
||||
"Assuming %s is huggingface model id, and downloading it.", model_id
|
||||
)
|
||||
import huggingface_hub
|
||||
|
||||
huggingface_hub.snapshot_download(repo_id=model_id)
|
||||
# Try to get the model path again after downloading.
|
||||
maybe_downloaded_model_path = get_model_entrypoint(model_id)
|
||||
assert Path(
|
||||
maybe_downloaded_model_path
|
||||
).exists(), f"Failed to download the model {model_id} to {maybe_downloaded_model_path}"
|
||||
return upload_model_files(maybe_downloaded_model_path, bucket_uri)
|
||||
else:
|
||||
return upload_model_files(maybe_downloaded_model_path, bucket_uri)
|
||||
|
||||
uploader = CloudModelUploader(model_id, CloudMirrorConfig(bucket_uri=bucket_uri))
|
||||
return uploader.upload_model()
|
||||
|
||||
|
||||
def upload_model_cli(
|
||||
model_source: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
help="HuggingFace model ID to download, or local model path to upload",
|
||||
),
|
||||
],
|
||||
bucket_uri: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
help="The bucket uri to upload the model to, must start with `s3://` or `gs://`",
|
||||
),
|
||||
],
|
||||
):
|
||||
"""Upload the model files to cloud storage (s3 or gcs)."""
|
||||
upload_model_files(model_source, bucket_uri)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user