chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,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
+68
View File
@@ -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