chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
from ray.llm._internal.common.observability.logging_utils import (
|
||||
disable_datasets_logger,
|
||||
disable_vllm_custom_ops_logger_on_cpu_nodes,
|
||||
)
|
||||
from ray.llm._internal.common.observability.telemetry_utils import Once
|
||||
from ray.llm._internal.serve.observability.logging.setup import (
|
||||
setup_logging,
|
||||
)
|
||||
|
||||
_setup_observability_once = Once()
|
||||
|
||||
|
||||
def _setup_observability():
|
||||
setup_logging()
|
||||
disable_datasets_logger()
|
||||
disable_vllm_custom_ops_logger_on_cpu_nodes()
|
||||
|
||||
|
||||
def setup_observability():
|
||||
_setup_observability_once.do_once(_setup_observability)
|
||||
|
||||
|
||||
__all__ = ["setup_observability"]
|
||||
@@ -0,0 +1,39 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ray._common.filters import CoreContextFilter
|
||||
from ray.serve._private.logging_utils import ServeContextFilter
|
||||
|
||||
|
||||
def _setup_logger(logger_name: str):
|
||||
"""Setup logger given the logger name.
|
||||
|
||||
This function is idempotent and won't set up the same logger multiple times. It will
|
||||
Also skip the setup if Serve logger is already setup and has handlers.
|
||||
"""
|
||||
logger = logging.getLogger(logger_name)
|
||||
serve_logger = logging.getLogger("ray.serve")
|
||||
|
||||
# Skip setup if the logger already has handlers setup or if the parent (Serve
|
||||
# logger) has handlers.
|
||||
if logger.handlers or serve_logger.handlers:
|
||||
return
|
||||
|
||||
# Set up stream handler, which logs to console as plaintext.
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.addFilter(CoreContextFilter())
|
||||
stream_handler.addFilter(ServeContextFilter())
|
||||
logger.addHandler(stream_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
def get_logger(name: Optional[str] = None):
|
||||
"""Get a structured logger inherited from the Ray Serve logger.
|
||||
|
||||
Loggers by default are logging to stdout, and are expected to be scraped by an
|
||||
external process.
|
||||
"""
|
||||
logger_name = f"ray.serve.{name}"
|
||||
_setup_logger(logger_name)
|
||||
return logging.getLogger(logger_name)
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
|
||||
from ray._common.filters import CoreContextFilter
|
||||
from ray._common.formatters import JSONFormatter
|
||||
from ray.serve._private.logging_utils import ServeContextFilter
|
||||
|
||||
|
||||
def _configure_stdlib_logging():
|
||||
"""Configures stdlib root logger to make sure stdlib loggers (created as
|
||||
`logging.getLogger(...)`) are using Ray's `JSONFormatter` with Core and Serve
|
||||
context filters.
|
||||
"""
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.addFilter(CoreContextFilter())
|
||||
handler.addFilter(ServeContextFilter())
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
# NOTE: It's crucial we reset all the handlers of the root logger,
|
||||
# to make sure that logs aren't emitted twice
|
||||
root_logger.handlers = []
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def setup_logging():
|
||||
_configure_stdlib_logging()
|
||||
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from ray.llm._internal.serve.observability.logging import get_logger
|
||||
from ray.util import metrics
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_METRICS_LOOP_INTERVAL = 5 # 5 seconds
|
||||
EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES = [
|
||||
0.05,
|
||||
0.1,
|
||||
0.15,
|
||||
0.20,
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.5,
|
||||
2.0,
|
||||
3.0,
|
||||
5.0,
|
||||
10.0,
|
||||
15.0,
|
||||
20.0,
|
||||
30.0,
|
||||
45.0,
|
||||
60.0,
|
||||
90.0,
|
||||
120.0,
|
||||
150.0,
|
||||
180.0,
|
||||
300.0,
|
||||
600.0,
|
||||
]
|
||||
|
||||
|
||||
def setup_event_loop_monitoring(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
scheduling_latency: metrics.Histogram,
|
||||
iterations: metrics.Counter,
|
||||
tasks: metrics.Gauge,
|
||||
tags: Dict[str, str],
|
||||
) -> asyncio.Task:
|
||||
return asyncio.create_task(
|
||||
_run_monitoring_loop(
|
||||
loop,
|
||||
schedule_latency=scheduling_latency,
|
||||
iterations=iterations,
|
||||
task_gauge=tasks,
|
||||
tags=tags,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _run_monitoring_loop(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
schedule_latency: metrics.Histogram,
|
||||
iterations: metrics.Counter,
|
||||
task_gauge: metrics.Gauge,
|
||||
tags: Dict[str, str],
|
||||
) -> None:
|
||||
while loop.is_running():
|
||||
iterations.inc(1, tags)
|
||||
num_tasks = len(asyncio.all_tasks())
|
||||
task_gauge.set(num_tasks, tags)
|
||||
yield_time = time.monotonic()
|
||||
await asyncio.sleep(_METRICS_LOOP_INTERVAL)
|
||||
elapsed_time = time.monotonic() - yield_time
|
||||
|
||||
# Historically, Ray's implementation of histograms are extremely finicky
|
||||
# with non-positive values (https://github.com/ray-project/ray/issues/26698).
|
||||
# Technically it shouldn't be possible for this to be negative, add the
|
||||
# max just to be safe.
|
||||
latency = max(0.0, elapsed_time - _METRICS_LOOP_INTERVAL)
|
||||
schedule_latency.observe(latency, tags)
|
||||
@@ -0,0 +1,133 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ray.llm._internal.serve.constants import ENABLE_VERBOSE_TELEMETRY
|
||||
from ray.llm._internal.serve.observability.logging import get_logger
|
||||
from ray.llm._internal.serve.observability.metrics.event_loop_monitoring import (
|
||||
EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES,
|
||||
setup_event_loop_monitoring,
|
||||
)
|
||||
from ray.llm._internal.serve.observability.metrics.fastapi_utils import (
|
||||
FASTAPI_API_SERVER_TAG_KEY,
|
||||
FASTAPI_BASE_HTTP_METRIC_TAG_KEYS,
|
||||
get_app_name,
|
||||
)
|
||||
from ray.llm._internal.serve.observability.metrics.middleware import (
|
||||
MeasureHTTPRequestMetricsMiddleware,
|
||||
)
|
||||
from ray.util import metrics
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ray_llm_build_info_gauge = metrics.Gauge(
|
||||
"ray_llm_build_info",
|
||||
description="Metadata about the ray-llm build.",
|
||||
tag_keys=("git_commit",),
|
||||
)
|
||||
|
||||
|
||||
_HTTP_HANDLER_LATENCY_S_HISTOGRAM_BUCKETS = [
|
||||
0.01,
|
||||
0.05,
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1,
|
||||
1.5,
|
||||
2,
|
||||
5,
|
||||
10,
|
||||
30,
|
||||
60,
|
||||
120,
|
||||
300,
|
||||
]
|
||||
|
||||
|
||||
async def add_fastapi_event_loop_monitoring(app: FastAPI):
|
||||
tags = {FASTAPI_API_SERVER_TAG_KEY: get_app_name(app)}
|
||||
tag_keys = tuple(tags.keys())
|
||||
|
||||
# Store the task handle to prevent it from being garbage collected
|
||||
app.state.fastapi_event_loop_schedule_latency = metrics.Histogram(
|
||||
"fastapi_event_loop_schedule_latency",
|
||||
description="Latency of getting yielded control on the FastAPI event loop in seconds",
|
||||
boundaries=EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES,
|
||||
tag_keys=tag_keys,
|
||||
)
|
||||
app.state.fastapi_event_loop_monitoring_iterations = metrics.Counter(
|
||||
"fastapi_event_loop_monitoring_iterations",
|
||||
description="Number of times the FastAPI event loop has iterated to get anyscale_fastapi_event_loop_schedule_latency.",
|
||||
tag_keys=tag_keys,
|
||||
)
|
||||
app.state.fastapi_event_loop_monitoring_tasks = metrics.Gauge(
|
||||
"fastapi_event_loop_monitoring_tasks",
|
||||
description="Number of outstanding tasks on the FastAPI event loop.",
|
||||
tag_keys=tag_keys,
|
||||
)
|
||||
|
||||
app.state.fastapi_event_loop_schedule_latency_metrics_task = (
|
||||
setup_event_loop_monitoring(
|
||||
asyncio.get_running_loop(),
|
||||
app.state.fastapi_event_loop_schedule_latency,
|
||||
app.state.fastapi_event_loop_monitoring_iterations,
|
||||
app.state.fastapi_event_loop_monitoring_tasks,
|
||||
tags,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def add_http_metrics_middleware(app: FastAPI):
|
||||
if not ENABLE_VERBOSE_TELEMETRY:
|
||||
logger.debug(
|
||||
"ENABLE_VERBOSE_TELEMETRY is false, not setting up FastAPI telemetry"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("ENABLE_VERBOSE_TELEMETRY is true, setting up FastAPI telemetry")
|
||||
base_tag_keys = FASTAPI_BASE_HTTP_METRIC_TAG_KEYS
|
||||
|
||||
logger.debug("Setting up FastAPI telemetry")
|
||||
|
||||
app.state.http_requests_metrics = metrics.Counter(
|
||||
"http_requests",
|
||||
description=(
|
||||
"Total number of HTTP requests by status code, handler and method."
|
||||
),
|
||||
tag_keys=base_tag_keys,
|
||||
)
|
||||
# NOTE: Custom decorators are not applied to histogram-based metrics
|
||||
# to make sure we can keep cardinality of those in check
|
||||
app.state.http_requests_latency_metrics = metrics.Histogram(
|
||||
"http_request_duration_seconds",
|
||||
description="Duration in seconds of HTTP requests.",
|
||||
boundaries=_HTTP_HANDLER_LATENCY_S_HISTOGRAM_BUCKETS,
|
||||
tag_keys=base_tag_keys,
|
||||
)
|
||||
|
||||
app.add_middleware(MeasureHTTPRequestMetricsMiddleware)
|
||||
|
||||
logger.debug("Setting up FastAPI telemetry completed")
|
||||
|
||||
|
||||
async def set_ray_llm_build_info():
|
||||
git_commit = os.environ.get("GIT_COMMIT")
|
||||
if git_commit:
|
||||
tags = {"git_commit": git_commit}
|
||||
ray_llm_build_info_gauge.set(1, tags)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def metrics_lifespan(app: FastAPI):
|
||||
"""Lifespan for a FastAPI app that sets up metrics observability."""
|
||||
|
||||
if ENABLE_VERBOSE_TELEMETRY:
|
||||
await add_fastapi_event_loop_monitoring(app)
|
||||
|
||||
await set_ray_llm_build_info()
|
||||
|
||||
yield
|
||||
@@ -0,0 +1,26 @@
|
||||
"""This file contains constants and utility functions for FastAPI."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# These tag keys are used in metrics for the FastAPI app.
|
||||
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY = "code"
|
||||
FASTAPI_HTTP_HANDLER_TAG_KEY = "handler"
|
||||
FASTAPI_HTTP_METHOD_TAG_KEY = "method"
|
||||
FASTAPI_HTTP_PATH_TAG_KEY = "path"
|
||||
FASTAPI_HTTP_USER_ID_TAG_KEY = "user_id"
|
||||
FASTAPI_API_SERVER_TAG_KEY = "api_server"
|
||||
|
||||
FASTAPI_BASE_HTTP_METRIC_TAG_KEYS = (
|
||||
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY,
|
||||
FASTAPI_HTTP_HANDLER_TAG_KEY,
|
||||
FASTAPI_HTTP_METHOD_TAG_KEY,
|
||||
FASTAPI_HTTP_PATH_TAG_KEY,
|
||||
FASTAPI_HTTP_USER_ID_TAG_KEY,
|
||||
FASTAPI_API_SERVER_TAG_KEY,
|
||||
)
|
||||
|
||||
|
||||
def get_app_name(app: FastAPI) -> str:
|
||||
"""Gets the FastAPI app name."""
|
||||
|
||||
return getattr(app.state, "name", "unknown")
|
||||
@@ -0,0 +1,150 @@
|
||||
import time
|
||||
from asyncio import CancelledError
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Message
|
||||
|
||||
from ray.llm._internal.serve.core.ingress.middleware import (
|
||||
get_request_id,
|
||||
get_user_id,
|
||||
)
|
||||
from ray.llm._internal.serve.observability.logging import get_logger
|
||||
from ray.llm._internal.serve.observability.metrics.fastapi_utils import (
|
||||
FASTAPI_API_SERVER_TAG_KEY,
|
||||
FASTAPI_HTTP_HANDLER_TAG_KEY,
|
||||
FASTAPI_HTTP_METHOD_TAG_KEY,
|
||||
FASTAPI_HTTP_PATH_TAG_KEY,
|
||||
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY,
|
||||
FASTAPI_HTTP_USER_ID_TAG_KEY,
|
||||
get_app_name,
|
||||
)
|
||||
from ray.serve._private.thirdparty.get_asgi_route_name import _get_route_name
|
||||
|
||||
logger = get_logger("ray.serve")
|
||||
|
||||
|
||||
class MeasureHTTPRequestMetricsMiddleware:
|
||||
"""Measures and stores HTTP request metrics."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
# If the status_code isn't set by send_wrapper,
|
||||
# we should consider that an error.
|
||||
status_code = 500
|
||||
send_wrapper_failed_exc_info = "Status code was never set by send_wrapper."
|
||||
exception_info = send_wrapper_failed_exc_info
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
"""Wraps the send message.
|
||||
|
||||
Enables this middleware to access the response headers.
|
||||
"""
|
||||
|
||||
nonlocal status_code, exception_info
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message.get("status", 500)
|
||||
|
||||
# Clear the send_wrapper_failed_exc_info.
|
||||
if exception_info == send_wrapper_failed_exc_info:
|
||||
exception_info = None
|
||||
|
||||
await send(message)
|
||||
|
||||
request = Request(scope)
|
||||
req_id = get_request_id(request)
|
||||
now = time.monotonic()
|
||||
try:
|
||||
logger.info(f"Starting handling of the request {req_id}")
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
except CancelledError as ce:
|
||||
status_code = -1
|
||||
exception_info = ce
|
||||
raise
|
||||
|
||||
except BaseException as e:
|
||||
status_code = 500
|
||||
exception_info = e
|
||||
raise
|
||||
|
||||
finally:
|
||||
duration_s = time.monotonic() - now
|
||||
|
||||
tags = _get_tags(request, status_code, request.app)
|
||||
# NOTE: Custom decorators are not applied to histogram-based metrics
|
||||
# to make sure we can keep cardinality of those in check
|
||||
truncated_tags = {
|
||||
**tags,
|
||||
FASTAPI_HTTP_USER_ID_TAG_KEY: "truncated",
|
||||
}
|
||||
|
||||
request.app.state.http_requests_metrics.inc(1, tags)
|
||||
request.app.state.http_requests_latency_metrics.observe(
|
||||
duration_s, truncated_tags
|
||||
)
|
||||
|
||||
extra_context = {
|
||||
"status_code": status_code,
|
||||
"duration_ms": duration_s * 1000,
|
||||
}
|
||||
|
||||
if status_code >= 400:
|
||||
log = logger.error if status_code >= 500 else logger.warning
|
||||
log(
|
||||
f"Handling of the request {req_id} failed",
|
||||
exc_info=exception_info,
|
||||
extra={"ray_serve_extra_fields": extra_context},
|
||||
)
|
||||
elif status_code == -1:
|
||||
logger.info(
|
||||
f"Handling of the request {req_id} have been cancelled",
|
||||
extra={"ray_serve_extra_fields": extra_context},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Handling of the request {req_id} successfully completed",
|
||||
extra={"ray_serve_extra_fields": extra_context},
|
||||
)
|
||||
|
||||
|
||||
def _get_route_details(scope: dict) -> Optional[str]:
|
||||
"""
|
||||
Function to retrieve Starlette route from scope.
|
||||
TODO: there is currently no way to retrieve http.route from
|
||||
a starlette application from scope.
|
||||
See: https://github.com/encode/starlette/pull/804
|
||||
Args:
|
||||
scope: A Starlette scope
|
||||
Returns:
|
||||
A string containing the route or None
|
||||
"""
|
||||
# Delegate to Serve's shared route-name resolver, which walks the route tree
|
||||
# and handles FastAPI >= 0.137 `_IncludedRouter` nodes (added by
|
||||
# `include_router`) that have no `.path` attribute of their own. Accessing
|
||||
# `.path` on such a node previously raised AttributeError here (#64245).
|
||||
return _get_route_name(scope, scope["app"].routes)
|
||||
|
||||
|
||||
def _get_tags(request: Request, status_code: int, app: FastAPI) -> Dict[str, str]:
|
||||
"""Generates tags for the request's metrics."""
|
||||
|
||||
route = str(_get_route_details(request.scope)) or "unknown"
|
||||
path = str(request.url.path) or "unknown"
|
||||
method = str(request.method) or "unknown"
|
||||
user_id = str(get_user_id(request) or "unknown")
|
||||
|
||||
return {
|
||||
FASTAPI_API_SERVER_TAG_KEY: get_app_name(app),
|
||||
FASTAPI_HTTP_RESPONSE_CODE_TAG_KEY: str(status_code),
|
||||
FASTAPI_HTTP_PATH_TAG_KEY: path,
|
||||
FASTAPI_HTTP_HANDLER_TAG_KEY: route,
|
||||
FASTAPI_HTTP_METHOD_TAG_KEY: method,
|
||||
FASTAPI_HTTP_USER_ID_TAG_KEY: user_id,
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
AsyncGenerator,
|
||||
Callable,
|
||||
List,
|
||||
Set,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from ray.util import metrics
|
||||
|
||||
# Histogram buckets for short-range latencies measurements:
|
||||
# Min=1ms, Max=30s
|
||||
#
|
||||
# NOTE: Number of buckets have to be bounded (and not exceed 30)
|
||||
# to avoid overloading metrics sub-system
|
||||
SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS: List[float] = [
|
||||
1,
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
150,
|
||||
250,
|
||||
500,
|
||||
1000,
|
||||
1500,
|
||||
2500,
|
||||
5000,
|
||||
7500,
|
||||
10000,
|
||||
20000,
|
||||
30000,
|
||||
]
|
||||
|
||||
# Histogram buckets for long-range latencies measurements:
|
||||
# Min=10ms, Max=300s
|
||||
LONG_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS = [
|
||||
x * 10 for x in SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS
|
||||
]
|
||||
|
||||
|
||||
class ClockUnit(int, Enum):
|
||||
ms = 1000
|
||||
s = 1
|
||||
|
||||
|
||||
class MsClock:
|
||||
"""A clock that tracks intervals in milliseconds"""
|
||||
|
||||
def __init__(self, unit: ClockUnit = ClockUnit.ms):
|
||||
self.reset()
|
||||
self.unit = unit.value
|
||||
self.start_time = time.perf_counter()
|
||||
|
||||
def reset(self):
|
||||
self.start_time = time.perf_counter()
|
||||
|
||||
def interval(self):
|
||||
return (time.perf_counter() - self.start_time) * self.unit
|
||||
|
||||
def reset_interval(self):
|
||||
interval = self.interval()
|
||||
self.reset()
|
||||
return interval
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class InstrumentTokenAsyncGenerator:
|
||||
"""This class instruments an asynchronous generator.
|
||||
|
||||
It gathers 3 metrics:
|
||||
1. Time to first time
|
||||
2. Time between tokens
|
||||
3. Total completion time
|
||||
|
||||
Usage:
|
||||
|
||||
@InstrumentTokenAsyncGenerator("my_special_fn")
|
||||
async def to_instrument():
|
||||
yield ...
|
||||
"""
|
||||
|
||||
all_instrument_names: Set[str] = set()
|
||||
|
||||
def __init__(
|
||||
self, generator_name: str, latency_histogram_buckets: List[float] = None
|
||||
):
|
||||
self.generator_name = f"rayllm_{generator_name}"
|
||||
|
||||
target_latency_histogram_buckets = (
|
||||
latency_histogram_buckets or SHORT_RANGE_LATENCY_HISTOGRAM_BUCKETS_MS
|
||||
)
|
||||
|
||||
assert (
|
||||
self.generator_name not in self.all_instrument_names
|
||||
), "This generator name was already used elsewhere. Please specify another one."
|
||||
self.all_instrument_names.add(self.generator_name)
|
||||
|
||||
self.token_latency_histogram = metrics.Histogram(
|
||||
f"{self.generator_name}_per_token_latency_ms",
|
||||
f"Generator metrics for {self.generator_name}",
|
||||
boundaries=target_latency_histogram_buckets,
|
||||
)
|
||||
|
||||
self.first_token_latency_histogram = metrics.Histogram(
|
||||
f"{self.generator_name}_first_token_latency_ms",
|
||||
f"Generator metrics for {self.generator_name}",
|
||||
boundaries=target_latency_histogram_buckets,
|
||||
)
|
||||
self.total_latency_histogram = metrics.Histogram(
|
||||
f"{self.generator_name}_total_latency_ms",
|
||||
f"Generator metrics for {self.generator_name}",
|
||||
boundaries=target_latency_histogram_buckets,
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self, async_generator_fn: Callable[..., AsyncGenerator[T, None]]
|
||||
) -> Callable[..., AsyncGenerator[T, None]]:
|
||||
async def new_gen(*args, **kwargs):
|
||||
interval_clock = MsClock()
|
||||
total_clock = MsClock()
|
||||
is_first_token = True
|
||||
try:
|
||||
async for x in async_generator_fn(*args, **kwargs):
|
||||
if is_first_token:
|
||||
self.first_token_latency_histogram.observe(
|
||||
total_clock.interval()
|
||||
)
|
||||
interval_clock.reset()
|
||||
is_first_token = False
|
||||
else:
|
||||
self.token_latency_histogram.observe(
|
||||
interval_clock.reset_interval()
|
||||
)
|
||||
yield x
|
||||
finally:
|
||||
self.total_latency_histogram.observe(total_clock.interval())
|
||||
|
||||
return new_gen
|
||||
@@ -0,0 +1,353 @@
|
||||
import hashlib
|
||||
import random
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
|
||||
from ray._common.usage.usage_lib import (
|
||||
get_hardware_usages_to_report,
|
||||
record_extra_usage_tag,
|
||||
)
|
||||
from ray.llm._internal.common.base_pydantic import BaseModelExtended
|
||||
from ray.llm._internal.common.observability.telemetry_utils import DEFAULT_GPU_TYPE
|
||||
from ray.llm._internal.common.utils.lora_utils import get_lora_model_ids
|
||||
from ray.llm._internal.serve.observability.logging import get_logger
|
||||
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
|
||||
LLM_SERVE_TELEMETRY_NAMESPACE = "llm_serve_telemetry"
|
||||
LLM_SERVE_TELEMETRY_ACTOR_NAME = "llm_serve_telemetry"
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TelemetryTags(str, Enum):
|
||||
"""Telemetry tags for LLM SERVE."""
|
||||
|
||||
LLM_SERVE_SERVE_MULTIPLE_MODELS = "LLM_SERVE_SERVE_MULTIPLE_MODELS"
|
||||
LLM_SERVE_SERVE_MULTIPLE_APPS = "LLM_SERVE_SERVE_MULTIPLE_APPS"
|
||||
LLM_SERVE_LORA_BASE_MODELS = "LLM_SERVE_LORA_BASE_MODELS"
|
||||
LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS = "LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS"
|
||||
LLM_SERVE_AUTOSCALING_ENABLED_MODELS = "LLM_SERVE_AUTOSCALING_ENABLED_MODELS"
|
||||
LLM_SERVE_AUTOSCALING_MIN_REPLICAS = "LLM_SERVE_AUTOSCALING_MIN_REPLICAS"
|
||||
LLM_SERVE_AUTOSCALING_MAX_REPLICAS = "LLM_SERVE_AUTOSCALING_MAX_REPLICAS"
|
||||
LLM_SERVE_TENSOR_PARALLEL_DEGREE = "LLM_SERVE_TENSOR_PARALLEL_DEGREE"
|
||||
LLM_SERVE_NUM_REPLICAS = "LLM_SERVE_NUM_REPLICAS"
|
||||
LLM_SERVE_MODELS = "LLM_SERVE_MODELS"
|
||||
LLM_SERVE_GPU_TYPE = "LLM_SERVE_GPU_TYPE"
|
||||
LLM_SERVE_NUM_GPUS = "LLM_SERVE_NUM_GPUS"
|
||||
|
||||
|
||||
class TelemetryModel(BaseModelExtended):
|
||||
"""Telemetry model for LLM Serve.
|
||||
|
||||
``model_id_hash`` is the dedup identity used by the telemetry agent and is
|
||||
never recorded as a tag value. It is a hash of the model id so the raw model
|
||||
name never reaches the head-node actor.
|
||||
"""
|
||||
|
||||
model_id_hash: str
|
||||
model_architecture: str
|
||||
num_replicas: int
|
||||
use_lora: bool
|
||||
initial_num_lora_adapters: int
|
||||
use_autoscaling: bool
|
||||
min_replicas: int
|
||||
max_replicas: int
|
||||
tensor_parallel_degree: int
|
||||
gpu_type: str
|
||||
num_gpus: int
|
||||
|
||||
|
||||
@ray.remote(
|
||||
name=LLM_SERVE_TELEMETRY_ACTOR_NAME,
|
||||
namespace=LLM_SERVE_TELEMETRY_NAMESPACE,
|
||||
num_cpus=0,
|
||||
lifetime="detached",
|
||||
)
|
||||
class TelemetryAgent:
|
||||
"""Named Actor to keep the state of all deployed models and record telemetry."""
|
||||
|
||||
def __init__(self):
|
||||
# Keyed by model_id_hash so repeated reports from replicas/restarts of
|
||||
# the same model overwrite rather than accumulate.
|
||||
self.models: Dict[str, TelemetryModel] = {}
|
||||
self.record_tag_func = record_extra_usage_tag
|
||||
|
||||
def _update_record_tag_func(self, record_tag_func: Callable) -> None:
|
||||
"""This method is only used in tests to record the telemetry tags to a different
|
||||
object than Ray's default `record_extra_usage_tag` function."""
|
||||
self.record_tag_func = record_tag_func
|
||||
|
||||
def _reset_models(self):
|
||||
"""This method is only used in tests to clean up the models list."""
|
||||
self.models = {}
|
||||
|
||||
def _multiple_models(self) -> str:
|
||||
unique_models = {model.model_architecture for model in self.models.values()}
|
||||
return "1" if len(unique_models) > 1 else "0"
|
||||
|
||||
@staticmethod
|
||||
def _multiple_apps() -> str:
|
||||
try:
|
||||
try:
|
||||
serve_status = serve.status()
|
||||
except ray.exceptions.ActorDiedError:
|
||||
# In a workspace with multiple Serve sessions, the long-running
|
||||
# telemetry agent may still be connected to a previous, now-dead
|
||||
# session. Shut down so we can reconnect to the live one.
|
||||
serve.shutdown()
|
||||
serve_status = serve.status()
|
||||
return "1" if len(serve_status.applications) > 1 else "0"
|
||||
except Exception:
|
||||
# Telemetry must never fail; fall back to "not multiple".
|
||||
logger.debug("Failed to query serve.status() for telemetry", exc_info=True)
|
||||
return "0"
|
||||
|
||||
def _lora_base_nodes(self) -> str:
|
||||
return ",".join(
|
||||
[
|
||||
model.model_architecture
|
||||
for model in self.models.values()
|
||||
if model.use_lora
|
||||
]
|
||||
)
|
||||
|
||||
def _lora_initial_num_adaptors(self) -> str:
|
||||
return ",".join(
|
||||
[
|
||||
str(model.initial_num_lora_adapters)
|
||||
for model in self.models.values()
|
||||
if model.use_lora
|
||||
]
|
||||
)
|
||||
|
||||
def _autoscaling_enabled_models(self) -> str:
|
||||
return ",".join(
|
||||
[
|
||||
model.model_architecture
|
||||
for model in self.models.values()
|
||||
if model.use_autoscaling
|
||||
]
|
||||
)
|
||||
|
||||
def _autoscaling_min_replicas(self) -> str:
|
||||
return ",".join(
|
||||
[
|
||||
str(model.min_replicas)
|
||||
for model in self.models.values()
|
||||
if model.use_autoscaling
|
||||
]
|
||||
)
|
||||
|
||||
def _autoscaling_max_replicas(self) -> str:
|
||||
return ",".join(
|
||||
[
|
||||
str(model.max_replicas)
|
||||
for model in self.models.values()
|
||||
if model.use_autoscaling
|
||||
]
|
||||
)
|
||||
|
||||
def _model_architectures(self) -> str:
|
||||
return ",".join([model.model_architecture for model in self.models.values()])
|
||||
|
||||
def _tensor_parallel_degree(self) -> str:
|
||||
return ",".join(
|
||||
[str(model.tensor_parallel_degree) for model in self.models.values()]
|
||||
)
|
||||
|
||||
def _num_replicas(self) -> str:
|
||||
return ",".join([str(model.num_replicas) for model in self.models.values()])
|
||||
|
||||
def _gpu_type(self) -> str:
|
||||
return ",".join([model.gpu_type for model in self.models.values()])
|
||||
|
||||
def _num_gpus(self) -> str:
|
||||
return ",".join([str(model.num_gpus) for model in self.models.values()])
|
||||
|
||||
def generate_report(self) -> Dict[str, str]:
|
||||
return {
|
||||
TelemetryTags.LLM_SERVE_SERVE_MULTIPLE_MODELS: self._multiple_models(),
|
||||
TelemetryTags.LLM_SERVE_SERVE_MULTIPLE_APPS: self._multiple_apps(),
|
||||
TelemetryTags.LLM_SERVE_LORA_BASE_MODELS: self._lora_base_nodes(),
|
||||
TelemetryTags.LLM_SERVE_INITIAL_NUM_LORA_ADAPTERS: self._lora_initial_num_adaptors(),
|
||||
TelemetryTags.LLM_SERVE_AUTOSCALING_ENABLED_MODELS: self._autoscaling_enabled_models(),
|
||||
TelemetryTags.LLM_SERVE_AUTOSCALING_MIN_REPLICAS: self._autoscaling_min_replicas(),
|
||||
TelemetryTags.LLM_SERVE_AUTOSCALING_MAX_REPLICAS: self._autoscaling_max_replicas(),
|
||||
TelemetryTags.LLM_SERVE_MODELS: self._model_architectures(),
|
||||
TelemetryTags.LLM_SERVE_TENSOR_PARALLEL_DEGREE: self._tensor_parallel_degree(),
|
||||
TelemetryTags.LLM_SERVE_NUM_REPLICAS: self._num_replicas(),
|
||||
TelemetryTags.LLM_SERVE_GPU_TYPE: self._gpu_type(),
|
||||
TelemetryTags.LLM_SERVE_NUM_GPUS: self._num_gpus(),
|
||||
}
|
||||
|
||||
def record(self, model: Optional[TelemetryModel] = None) -> None:
|
||||
"""Record telemetry model."""
|
||||
from ray._common.usage.usage_lib import TagKey
|
||||
|
||||
if model:
|
||||
self.models[model.model_id_hash] = model
|
||||
|
||||
for key, value in self.generate_report().items():
|
||||
try:
|
||||
self.record_tag_func(TagKey.Value(key), value)
|
||||
except ValueError:
|
||||
# If the key doesn't exist in the TagKey enum, skip it.
|
||||
continue
|
||||
|
||||
|
||||
def _get_or_create_telemetry_agent() -> TelemetryAgent:
|
||||
"""Get or create the detached telemetry agent.
|
||||
|
||||
``get_if_exists`` makes creation atomic, so concurrent replicas converge on a
|
||||
single actor without racing on the name.
|
||||
"""
|
||||
return TelemetryAgent.options(
|
||||
name=LLM_SERVE_TELEMETRY_ACTOR_NAME,
|
||||
namespace=LLM_SERVE_TELEMETRY_NAMESPACE,
|
||||
get_if_exists=True,
|
||||
# Ensure the actor is created on the head node.
|
||||
resources={HEAD_NODE_RESOURCE_NAME: 0.001},
|
||||
# Ensure the actor is not scheduled with the existing placement group.
|
||||
scheduling_strategy=PlacementGroupSchedulingStrategy(placement_group=None),
|
||||
).remote()
|
||||
|
||||
|
||||
def _retry_get_telemetry_agent(
|
||||
max_retries: int = 5, base_delay: float = 0.1
|
||||
) -> TelemetryAgent:
|
||||
"""Get-or-create the telemetry agent, retrying transient failures."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return _get_or_create_telemetry_agent()
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Attempt %s/%s to get telemetry agent failed", attempt + 1, max_retries
|
||||
)
|
||||
if attempt == max_retries - 1:
|
||||
raise e
|
||||
# Exponential backoff with jitter; ~3.5s total over 5 attempts.
|
||||
time.sleep(base_delay * (2**attempt) + random.uniform(0, 0.5))
|
||||
|
||||
|
||||
def _push_telemetry_report(model: Optional[TelemetryModel] = None) -> None:
|
||||
"""Push telemetry report for a model."""
|
||||
telemetry_agent = _retry_get_telemetry_agent()
|
||||
assert telemetry_agent is not None
|
||||
ray.get(telemetry_agent.record.remote(model))
|
||||
|
||||
|
||||
class HardwareUsage:
|
||||
"""Hardware usage class to report telemetry."""
|
||||
|
||||
def __init__(self, get_hardware_fn: Callable = get_hardware_usages_to_report):
|
||||
self._get_hardware_fn = get_hardware_fn
|
||||
|
||||
def infer_gpu_from_hardware(self) -> str:
|
||||
"""Infer the GPU type from the hardware when the accelerator type on llm config is
|
||||
not specified.
|
||||
"""
|
||||
from ray.llm._internal.serve.core.configs.accelerators import AcceleratorType
|
||||
|
||||
all_accelerator_types = [t.value for t in AcceleratorType]
|
||||
gcs_client = ray.experimental.internal_kv.internal_kv_get_gcs_client()
|
||||
hardwares = self._get_hardware_fn(gcs_client)
|
||||
for hardware in hardwares:
|
||||
if hardware in all_accelerator_types:
|
||||
return hardware
|
||||
|
||||
return DEFAULT_GPU_TYPE
|
||||
|
||||
|
||||
def push_telemetry_report_for_all_models(
|
||||
all_models: Optional[Sequence["LLMConfig"]] = None,
|
||||
get_lora_model_func: Callable = get_lora_model_ids,
|
||||
get_hardware_fn: Callable = get_hardware_usages_to_report,
|
||||
):
|
||||
"""Push a telemetry report for each model. Never raises."""
|
||||
if not all_models:
|
||||
return
|
||||
|
||||
for model in all_models:
|
||||
# Telemetry must never break the caller (e.g. engine start).
|
||||
try:
|
||||
_push_model_telemetry(model, get_lora_model_func, get_hardware_fn)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to push telemetry for model %s",
|
||||
getattr(model, "model_id", "<unknown>"),
|
||||
)
|
||||
|
||||
|
||||
def _push_model_telemetry(
|
||||
model: "LLMConfig",
|
||||
get_lora_model_func: Callable,
|
||||
get_hardware_fn: Callable,
|
||||
) -> None:
|
||||
use_lora = (
|
||||
model.lora_config is not None
|
||||
and model.lora_config.dynamic_lora_loading_path is not None
|
||||
)
|
||||
initial_num_lora_adapters = 0
|
||||
if use_lora:
|
||||
lora_model_ids = get_lora_model_func(
|
||||
dynamic_lora_loading_path=model.lora_config.dynamic_lora_loading_path,
|
||||
base_model_id=model.model_id,
|
||||
)
|
||||
initial_num_lora_adapters = len(lora_model_ids)
|
||||
|
||||
deployment_config = model.deployment_config
|
||||
autoscaling_config = deployment_config.get("autoscaling_config")
|
||||
if deployment_config.get("num_replicas") == "auto":
|
||||
# "auto" resolves to an autoscaling config; mirror LLMConfig validation
|
||||
# since the stored deployment_config keeps the literal "auto".
|
||||
from ray.serve._private.config import handle_num_replicas_auto
|
||||
|
||||
_, autoscaling_config = handle_num_replicas_auto(
|
||||
deployment_config.get("max_ongoing_requests"), autoscaling_config
|
||||
)
|
||||
|
||||
use_autoscaling = autoscaling_config is not None
|
||||
if use_autoscaling:
|
||||
from ray.serve.config import AutoscalingConfig
|
||||
|
||||
if isinstance(autoscaling_config, dict):
|
||||
autoscaling_config = AutoscalingConfig(**autoscaling_config)
|
||||
num_replicas = (
|
||||
autoscaling_config.initial_replicas or autoscaling_config.min_replicas
|
||||
)
|
||||
min_replicas = autoscaling_config.min_replicas
|
||||
max_replicas = autoscaling_config.max_replicas
|
||||
else:
|
||||
# Fixed replica count; honor the configured value (including 0),
|
||||
# defaulting to 1 only when unset.
|
||||
num_replicas = deployment_config.get("num_replicas")
|
||||
if num_replicas is None:
|
||||
num_replicas = 1
|
||||
min_replicas = max_replicas = num_replicas
|
||||
|
||||
engine_config = model.get_engine_config()
|
||||
hardware_usage = HardwareUsage(get_hardware_fn)
|
||||
|
||||
telemetry_model = TelemetryModel(
|
||||
# Hash so the cleartext model name (possibly proprietary) never reaches
|
||||
# the head-node actor; deterministic across replicas/restarts so dedup holds.
|
||||
model_id_hash=hashlib.sha256(model.model_id.encode("utf-8")).hexdigest(),
|
||||
model_architecture=model.model_architecture,
|
||||
num_replicas=num_replicas,
|
||||
use_lora=use_lora,
|
||||
initial_num_lora_adapters=initial_num_lora_adapters,
|
||||
use_autoscaling=use_autoscaling,
|
||||
min_replicas=min_replicas,
|
||||
max_replicas=max_replicas,
|
||||
tensor_parallel_degree=engine_config.tensor_parallel_degree,
|
||||
gpu_type=model.accelerator_type or hardware_usage.infer_gpu_from_hardware(),
|
||||
num_gpus=engine_config.num_devices,
|
||||
)
|
||||
_push_telemetry_report(telemetry_model)
|
||||
Reference in New Issue
Block a user