chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,31 @@
import threading
import time
import psutil
def start_cpu_monitor_thread(component: str, interval: float = 5.0) -> threading.Thread:
from prometheus_client import Counter
cpu_seconds_total = Counter(
name="sglang:process_cpu_seconds_total",
documentation="Total CPU time consumed by this process (user + system)",
labelnames=["component"],
)
def monitor():
process = psutil.Process()
last_times = process.cpu_times()
while True:
time.sleep(interval)
curr_times = process.cpu_times()
delta = (curr_times.user - last_times.user) + (
curr_times.system - last_times.system
)
cpu_seconds_total.labels(component=component).inc(delta)
last_times = curr_times
t = threading.Thread(target=monitor, daemon=True)
t.start()
return t
@@ -0,0 +1,221 @@
"""
Forward pass metrics for per-iteration scheduler telemetry.
Emits per-iteration scheduling metrics over ZMQ PUB so that external
consumers can observe scheduler behavior in real time without polling
Prometheus.
Uses msgspec.Struct for zero-copy serialization.
Data flow::
Scheduler process:
SchedulerMetricsMixin._emit_forward_pass_metrics()
-> _FpmPublisherThread -> ZMQ PUB (localhost)
External consumer:
ZMQ SUB -> deserialize ForwardPassMetrics
"""
from __future__ import annotations
import logging
import queue
import threading
import time
from itertools import count
import msgspec
# Schema version. Must match the consumer (Dynamo's ForwardPassMetrics).
# Bump when the schema changes incompatibly.
FPM_VERSION: int = 1
logger = logging.getLogger(__name__)
class WelfordAccumulator:
"""Welford's online algorithm for count / total / population-variance.
Numerically stable single-pass computation.
"""
__slots__ = ("count", "total", "_mean", "_m2")
def __init__(self) -> None:
self.count = 0
self.total = 0
self._mean = 0.0
self._m2 = 0.0
def add(self, v: int) -> None:
self.count += 1
self.total += v
delta = v - self._mean
self._mean += delta / self.count
delta2 = v - self._mean
self._m2 += delta * delta2
def variance(self) -> float:
if self.count == 0:
return 0.0
return self._m2 / self.count
class ScheduledRequestMetrics(
msgspec.Struct,
frozen=True,
gc=False,
):
"""Metrics for requests scheduled in this iteration."""
num_prefill_requests: int = 0
sum_prefill_tokens: int = 0
var_prefill_length: float = 0.0
sum_prefill_kv_tokens: int = 0
num_decode_requests: int = 0
sum_decode_kv_tokens: int = 0
var_decode_kv_tokens: float = 0.0
class QueuedRequestMetrics(
msgspec.Struct,
frozen=True,
gc=False,
):
"""Metrics for requests waiting in the queue."""
num_prefill_requests: int = 0
sum_prefill_tokens: int = 0
var_prefill_length: float = 0.0
num_decode_requests: int = 0
sum_decode_kv_tokens: int = 0
var_decode_kv_tokens: float = 0.0
class ForwardPassMetrics(
msgspec.Struct,
frozen=True,
gc=False,
):
"""Per-iteration metrics emitted by the scheduler.
One message per scheduler iteration (one per forward pass).
``wall_time`` is the iteration duration in seconds.
An idle heartbeat (all zeros, wall_time=0) is emitted when the
engine transitions from active to idle.
Field order must match Dynamo's ``ForwardPassMetrics`` in
``dynamo.common.forward_pass_metrics`` — msgspec uses positional
encoding so any mismatch silently corrupts data.
"""
version: int = FPM_VERSION
worker_id: str = ""
dp_rank: int = 0
counter_id: int = 0
wall_time: float = 0.0
scheduled_requests: ScheduledRequestMetrics = ScheduledRequestMetrics()
queued_requests: QueuedRequestMetrics = QueuedRequestMetrics()
_encoder = msgspec.msgpack.Encoder()
_decoder = msgspec.msgpack.Decoder(ForwardPassMetrics)
def encode(metrics: ForwardPassMetrics) -> bytes:
return _encoder.encode(metrics)
def decode(data: bytes) -> ForwardPassMetrics:
return _decoder.decode(data)
class _FpmPublisherThread:
"""Background thread that serializes and sends ForwardPassMetrics over ZMQ.
Also emits periodic heartbeats when idle.
"""
SHUTDOWN_TIMEOUT: float = 1.0
HEARTBEAT_INTERVAL: float = 1.0
def __init__(
self,
endpoint: str,
worker_id: str,
dp_rank: int,
max_queue_size: int = 10_000,
) -> None:
import zmq
self._queue: queue.Queue[ForwardPassMetrics | None] = queue.Queue(
maxsize=max_queue_size
)
self._seq = count()
self._worker_id = worker_id
self._dp_rank = dp_rank
self._ctx = zmq.Context()
self._pub = self._ctx.socket(zmq.PUB)
self._pub.bind(endpoint)
self._zmq = zmq
self._running = True
self._thread = threading.Thread(
target=self._run, daemon=True, name="fpm-zmq-publisher"
)
self._thread.start()
def publish(self, metrics: ForwardPassMetrics) -> None:
if not self._running:
return
try:
self._queue.put_nowait(metrics)
except queue.Full:
pass
def shutdown(self) -> None:
self._running = False
try:
self._queue.put_nowait(None)
except queue.Full:
pass
self._thread.join(timeout=self.SHUTDOWN_TIMEOUT)
try:
self._pub.close(linger=0)
self._ctx.term()
except Exception:
pass
def _run(self) -> None:
zmq = self._zmq
topic = b""
last_publish = time.monotonic()
while self._running or not self._queue.empty():
try:
metrics = self._queue.get(timeout=self.HEARTBEAT_INTERVAL)
if metrics is None:
break
except queue.Empty:
if time.monotonic() - last_publish >= self.HEARTBEAT_INTERVAL:
metrics = ForwardPassMetrics(
worker_id=self._worker_id,
dp_rank=self._dp_rank,
)
else:
continue
try:
seq = next(self._seq)
metrics = msgspec.structs.replace(metrics, counter_id=seq)
payload = encode(metrics)
seq_bytes = seq.to_bytes(8, "big")
self._pub.send_multipart((topic, seq_bytes, payload), flags=zmq.NOBLOCK)
last_publish = time.monotonic()
except zmq.Again:
pass
except Exception:
logger.warning("FPM publisher send failed", exc_info=True)
@@ -0,0 +1,101 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Records the latency of some functions
"""
import asyncio
import time
from functools import wraps
from typing import Any, Callable, Optional
from sglang.srt.observability.utils import exponential_buckets
enable_metrics = False
def enable_func_timer():
# We need to import prometheus_client after setting the env variable `PROMETHEUS_MULTIPROC_DIR`
from prometheus_client import Histogram
global enable_metrics, FUNC_LATENCY
enable_metrics = True
FUNC_LATENCY = Histogram(
"sglang:func_latency_seconds",
"Function latency in seconds",
# captures latency in range [50ms - ~50s]
buckets=exponential_buckets(start=0.05, width=1.5, length=18),
labelnames=["name"],
)
FUNC_LATENCY = None
def time_func_latency(
func: Callable = None, name: Optional[str] = None
) -> Callable[..., Any]:
"""
A decorator to observe the latency of a function's execution. Supports both sync and async functions.
NOTE: We use our own implementation of a timer decorator since prometheus_client does not support async
context manager yet.
Overhead: The overhead introduced here in case of an async function could likely be because of `await` introduced
which will return in another coroutine object creation and under heavy load could see longer wall time
(scheduling delays due to introduction of another awaitable).
"""
def measure(func: Callable[..., Any]) -> Callable[..., Any]:
nonlocal name
name = name or func.__name__
@wraps(func)
async def async_wrapper(*args, **kwargs):
if not enable_metrics:
return await func(*args, **kwargs)
metric = FUNC_LATENCY
start = time.monotonic()
ret = func(*args, **kwargs)
if isinstance(ret, asyncio.Future) or asyncio.iscoroutine(ret):
try:
ret = await ret
finally:
metric.labels(name=name).observe(time.monotonic() - start)
return ret
@wraps(func)
def sync_wrapper(*args, **kwargs):
if not enable_metrics:
return func(*args, **kwargs)
metric = FUNC_LATENCY
start = time.monotonic()
try:
ret = func(*args, **kwargs)
finally:
metric.labels(name=name).observe(time.monotonic() - start)
return ret
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
if func:
return measure(func)
else:
return measure
@@ -0,0 +1,27 @@
from typing import Optional
_PRIORITY_MIN = 0
_PRIORITY_MAX = 31
_LOW_PRIORITY_VALUE = "LOW"
_HIGH_PRIORITY_VALUE = "HIGH"
UNKNOWN_PRIORITY_VALUE = "UNKNOWN"
def transform_priority(priority: Optional[int]) -> str:
"""Transform the priority to a string for metrics reporting.
Limit the range to prevent high cardinality issues.
Args:
priority: The priority to transform.
Returns:
The transformed priority.
"""
if priority is None:
return UNKNOWN_PRIORITY_VALUE
elif priority < _PRIORITY_MIN:
return _LOW_PRIORITY_VALUE
elif priority >= _PRIORITY_MAX:
return _HIGH_PRIORITY_VALUE
else:
return str(priority)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
import time
from typing import Union
from sglang.srt.observability.req_time_stats import (
RequestStageConfig,
convert_time_to_realtime_ns,
)
from sglang.srt.observability.trace import TraceNullContext, TraceReqContext
class MooncakeRequestStage:
MOONCAKE_SEND = RequestStageConfig(
"mooncake_send",
level=1,
)
MOONCAKE_RECV = RequestStageConfig(
"mooncake_recv",
level=1,
)
MOONCAKE_WORKER_SEND = RequestStageConfig(
"mooncake_worker_send",
level=1,
)
MOONCAKE_WORKER_SEND_SESSION = RequestStageConfig(
"mooncake_worker_send_session",
level=2,
)
MOONCAKE_WORKER_RECV = RequestStageConfig(
"mooncake_worker_recv",
level=1,
)
def mooncake_trace_slice(
trace_ctx: Union[TraceReqContext, TraceNullContext],
stage: RequestStageConfig,
start_ts: float,
thread_finish_flag=False,
):
if trace_ctx is None:
return
if not trace_ctx.tracing_enable:
return
start_ts = convert_time_to_realtime_ns(start_ts)
trace_ctx.trace_slice_start(stage.stage_name, stage.level, start_ts)
trace_ctx.trace_slice_end(
stage.stage_name,
stage.level,
thread_finish_flag=thread_finish_flag,
)
def mooncake_trace_func(stage: RequestStageConfig):
def decorator(func):
def wrapper(self, *args, **kwargs):
if self.trace_ctx is None:
return func(self, *args, **kwargs)
start_ts = convert_time_to_realtime_ns(time.perf_counter())
self.trace_ctx.trace_slice_start(stage.stage_name, stage.level, start_ts)
ret = func(self, *args, **kwargs)
self.trace_ctx.trace_slice_end(stage.stage_name, stage.level)
return ret
return wrapper
return decorator
@@ -0,0 +1,307 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Ray-backed implementations of the prometheus_client API surface used by
sglang's ``*MetricsCollector`` classes.
The wrappers translate prometheus_client calls into ``ray.util.metrics`` so the
metrics emitted by an embedded sglang engine flow through Ray's metric agent and
appear on Ray's Prometheus endpoint / dashboard alongside other Ray metrics.
Mirrors ``vllm/v1/metrics/ray_wrappers.py`` with two sglang-specific additions:
* ``RaySummaryWrapper`` — Ray has no Summary primitive; we fall back to a
Histogram with conservative default boundaries. Quantile queries can be
approximated through ``histogram_quantile()`` in Prometheus.
* Five collector subclasses, one per ``*MetricsCollector`` defined in
:mod:`sglang.srt.observability.metrics_collector`, overriding only the
``_xxx_cls`` attributes that the corresponding collector actually uses.
Import is lazy: the module loads in environments without Ray installed, but
instantiating a wrapper without Ray raises a clear :class:`ImportError`.
"""
from __future__ import annotations
import copy
import re
import time
from typing import List, Optional
try:
from ray import serve as ray_serve
from ray.util import metrics as ray_metrics
from ray.util.metrics import Metric
except ImportError: # pragma: no cover - covered by a dedicated test
ray_metrics = None
ray_serve = None
Metric = None # type: ignore[assignment]
from sglang.srt.observability.metrics_collector import (
ExpertDispatchCollector,
RadixCacheMetricsCollector,
SchedulerMetricsCollector,
StorageMetricsCollector,
TokenizerMetricsCollector,
)
def _get_replica_id() -> Optional[str]:
"""Return the current Ray Serve replica ID, or ``None`` outside Serve."""
if ray_serve is None:
return None
try:
return ray_serve.get_replica_context().replica_id.unique_id
except ray_serve.exceptions.RayServeException:
return None
class RayPrometheusMetric:
"""Base wrapper that exposes the prometheus_client API on Ray metrics.
Subclasses populate ``self.metric`` with a ``ray.util.metrics`` instance in
their ``__init__``. Shared behaviour:
* A ``ReplicaId`` tag is appended to every metric and populated at
instantiation (and again on each ``labels()`` call) so Ray-Serve replicas
are distinguishable on dashboards.
* ``labels()`` returns a fresh copy of the wrapper with its tags bound,
mirroring the ``prometheus_client`` pattern and avoiding state sharing
between concurrent emits.
* Metric names are sanitised to satisfy Ray's OpenTelemetry naming rule
(no ``:``, no other punctuation).
"""
_is_labeled: bool = False
def __init__(self) -> None:
if ray_metrics is None:
raise ImportError(
"RayPrometheusMetric requires Ray to be installed. "
"Install with: pip install 'ray[serve]'"
)
self.metric: Optional[Metric] = None
self._tags: dict = {"ReplicaId": _get_replica_id() or ""}
@staticmethod
def _get_tag_keys(labelnames: Optional[List[str]]) -> tuple:
labels = list(labelnames) if labelnames else []
labels.append("ReplicaId")
return tuple(labels)
def _build_tags(self, *labels: str, **labelskwargs: str) -> dict:
if labels:
# The trailing entry of ``_tag_keys`` is always ``ReplicaId`` which we
# populate ourselves; positional args fill the preceding keys only.
expected = len(self.metric._tag_keys) - 1
if len(labels) != expected:
raise ValueError(
"Number of labels must match the number of tag keys. "
f"Expected {expected}, got {len(labels)}"
)
labelskwargs.update(zip(self.metric._tag_keys, labels))
labelskwargs["ReplicaId"] = _get_replica_id() or ""
return {k: v if isinstance(v, str) else str(v) for k, v in labelskwargs.items()}
def labels(self, *labels: str, **labelskwargs: str) -> RayPrometheusMetric:
if self._is_labeled:
raise ValueError("labels() cannot be called on an already-labeled metric.")
clone = copy.copy(self)
clone._tags = self._build_tags(*labels, **labelskwargs)
clone._is_labeled = True
return clone
@staticmethod
def _coerce_positive_boundaries(buckets):
# Ray (gRPC OpenCensus / OpenTelemetry export) rejects boundaries
# <= 0. sglang ships several histograms whose lowest bucket is 0.0
# (e.g. queue_time, e2e latency). Silently drop those so we never
# break engine startup when the metrics backend is Ray.
if not buckets:
return []
return [b for b in buckets if b > 0]
@staticmethod
def _get_sanitized_opentelemetry_name(name: str) -> str:
"""Replace characters Ray's OTel-backed metric name validator rejects.
Ray is migrating from OpenCensus to OpenTelemetry, whose instrument names
only allow ``a-zA-Z0-9_``. sglang's existing names use a ``sglang:foo``
prefix; converting ``:`` (and any other punctuation) to ``_`` keeps the
names valid without churn on the prometheus_client side.
"""
return re.sub(r"[^a-zA-Z0-9_]", "_", name)
class RayCounterWrapper(RayPrometheusMetric):
"""``prometheus_client.Counter`` compatible wrapper."""
def __init__(
self,
name: str,
documentation: Optional[str] = "",
labelnames: Optional[List[str]] = None,
) -> None:
super().__init__()
tag_keys = self._get_tag_keys(labelnames)
name = self._get_sanitized_opentelemetry_name(name)
self.metric = ray_metrics.Counter(
name=name,
description=documentation,
tag_keys=tag_keys,
)
def inc(self, value: float = 1.0) -> None:
if value == 0:
return
return self.metric.inc(value, tags=self._tags)
class RayGaugeWrapper(RayPrometheusMetric):
"""``prometheus_client.Gauge`` compatible wrapper."""
def __init__(
self,
name: str,
documentation: Optional[str] = "",
labelnames: Optional[List[str]] = None,
multiprocess_mode: Optional[str] = "",
) -> None:
# Ray aggregates per WorkerId/ReplicaId at the metric agent, so the
# prometheus_client multiproc modes ("mostrecent", "all", "sum") are not
# meaningful here. Accept and discard for API parity.
del multiprocess_mode
super().__init__()
tag_keys = self._get_tag_keys(labelnames)
name = self._get_sanitized_opentelemetry_name(name)
self.metric = ray_metrics.Gauge(
name=name,
description=documentation,
tag_keys=tag_keys,
)
def set(self, value: float) -> None:
return self.metric.set(value, tags=self._tags)
def set_to_current_time(self) -> None:
return self.set(time.time())
class RayHistogramWrapper(RayPrometheusMetric):
"""``prometheus_client.Histogram`` compatible wrapper."""
def __init__(
self,
name: str,
documentation: Optional[str] = "",
labelnames: Optional[List[str]] = None,
buckets: Optional[List[float]] = None,
) -> None:
super().__init__()
tag_keys = self._get_tag_keys(labelnames)
name = self._get_sanitized_opentelemetry_name(name)
self.metric = ray_metrics.Histogram(
name=name,
description=documentation,
tag_keys=tag_keys,
boundaries=self._coerce_positive_boundaries(buckets),
)
def observe(self, value: float) -> None:
return self.metric.observe(value, tags=self._tags)
class RaySummaryWrapper(RayPrometheusMetric):
"""``prometheus_client.Summary`` compatible wrapper.
``ray.util.metrics`` does not provide a Summary primitive. We approximate by
emitting through a Histogram with conservative default boundaries; quantile
queries can be approximated downstream via ``histogram_quantile()``.
"""
DEFAULT_BOUNDARIES: List[float] = [
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1.0,
2.5,
5.0,
10.0,
]
def __init__(
self,
name: str,
documentation: Optional[str] = "",
labelnames: Optional[List[str]] = None,
) -> None:
super().__init__()
tag_keys = self._get_tag_keys(labelnames)
name = self._get_sanitized_opentelemetry_name(name)
self.metric = ray_metrics.Histogram(
name=name,
description=documentation,
tag_keys=tag_keys,
boundaries=self._coerce_positive_boundaries(self.DEFAULT_BOUNDARIES),
)
def observe(self, value: float) -> None:
return self.metric.observe(value, tags=self._tags)
# ---------------------------------------------------------------------------
# Collector subclasses
#
# Each subclass only overrides the ``_xxx_cls`` attributes its parent actually
# uses; the parent's ``_StatLoggerDIMixin`` defaults handle the rest.
# ---------------------------------------------------------------------------
class RaySchedulerMetricsCollector(SchedulerMetricsCollector):
"""``SchedulerMetricsCollector`` that emits via Ray's metric system."""
_counter_cls = RayCounterWrapper
_gauge_cls = RayGaugeWrapper
_histogram_cls = RayHistogramWrapper
_summary_cls = RaySummaryWrapper
class RayTokenizerMetricsCollector(TokenizerMetricsCollector):
"""``TokenizerMetricsCollector`` that emits via Ray's metric system."""
_counter_cls = RayCounterWrapper
_histogram_cls = RayHistogramWrapper
class RayStorageMetricsCollector(StorageMetricsCollector):
"""``StorageMetricsCollector`` that emits via Ray's metric system."""
_counter_cls = RayCounterWrapper
_histogram_cls = RayHistogramWrapper
class RayRadixCacheMetricsCollector(RadixCacheMetricsCollector):
"""``RadixCacheMetricsCollector`` that emits via Ray's metric system."""
_counter_cls = RayCounterWrapper
_histogram_cls = RayHistogramWrapper
class RayExpertDispatchCollector(ExpertDispatchCollector):
"""``ExpertDispatchCollector`` that emits via Ray's metric system."""
_histogram_cls = RayHistogramWrapper
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
import asyncio
import dataclasses
import json
import logging
import os
from abc import ABC, abstractmethod
from datetime import datetime
from typing import List, Optional, Union
from sglang.srt.constants import HEALTH_CHECK_RID_PREFIX
from sglang.srt.managers.io_struct import EmbeddingReqInput, GenerateReqInput
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
# Fields that should always be excluded from request parameters
# because they contain non-JSON-serializable objects (e.g., ImageData, tensors)
ALWAYS_EXCLUDE_FIELDS = {"image_data", "video_data", "audio_data", "input_embeds"}
class RequestMetricsExporter(ABC):
"""Abstract base class for exporting request-level performance metrics to a data destination."""
def __init__(
self,
server_args: ServerArgs,
obj_skip_names: Optional[set[str]],
out_skip_names: Optional[set[str]],
):
self.server_args = server_args
self.obj_skip_names = obj_skip_names or set()
self.out_skip_names = out_skip_names or set()
def _format_output_data(
self, obj: Union[GenerateReqInput, EmbeddingReqInput], out_dict: dict
) -> dict:
"""Format request-level output data containing performance metrics. This method
should be called prior to writing the data record with `self.write_record()`."""
request_params = {}
for field in dataclasses.fields(obj):
field_name = field.name
# Skip fields in obj_skip_names or fields that are always excluded (not JSON serializable)
if (
field_name not in self.obj_skip_names
and field_name not in ALWAYS_EXCLUDE_FIELDS
):
value = getattr(obj, field_name)
# Convert to serializable format
if value is not None:
request_params[field_name] = value
meta_info = out_dict.get("meta_info", {})
filtered_out_meta_info = {
k: v for k, v in meta_info.items() if k not in self.out_skip_names
}
request_output_data = {
"request_parameters": json.dumps(request_params),
**filtered_out_meta_info,
}
return request_output_data
@abstractmethod
async def write_record(
self, obj: Union[GenerateReqInput, EmbeddingReqInput], out_dict: dict
):
"""Write a data record corresponding to a single request, containing performance metric data."""
pass
class FileRequestMetricsExporter(RequestMetricsExporter):
"""Lightweight `RequestMetricsExporter` implementation that writes records to files on disk.
Records are written to files in the directory specified by `--export-metrics-to-file-dir`
server launch flag. File names are of the form `"sglang-request-metrics-{hour_suffix}.log"`.
"""
def __init__(
self,
server_args: ServerArgs,
obj_skip_names: Optional[set[str]],
out_skip_names: Optional[set[str]],
):
super().__init__(server_args, obj_skip_names, out_skip_names)
self.export_dir = getattr(server_args, "export_metrics_to_file_dir")
os.makedirs(self.export_dir, exist_ok=True)
# File handler state management
self._current_file_handler = None
self._current_file_lock = asyncio.Lock()
self._current_hour_suffix = None
def _ensure_file_handler(self, hour_suffix: str):
"""Ensure the file handler is open for the current hour suffix."""
if self._current_hour_suffix != hour_suffix:
# Close previous file handler if it exists
if self._current_file_handler is not None:
try:
self._current_file_handler.close()
except Exception as e:
logger.warning(f"Failed to close previous file handler: {e}")
# Open new file handler
log_filename = f"sglang-request-metrics-{hour_suffix}.log"
log_filepath = os.path.join(self.export_dir, log_filename)
try:
self._current_file_handler = open(log_filepath, "a", encoding="utf-8")
self._current_hour_suffix = hour_suffix
except Exception as e:
logger.error(f"Failed to open log file {log_filepath}: {e}")
self._current_file_handler = None
self._current_hour_suffix = None
raise
def close(self):
"""Close the current file handler."""
if self._current_file_handler is not None:
try:
self._current_file_handler.close()
except Exception as e:
logger.warning(f"Failed to close file handler: {e}")
finally:
self._current_file_handler = None
self._current_hour_suffix = None
async def write_record(
self, obj: Union[GenerateReqInput, EmbeddingReqInput], out_dict: dict
):
# Do not log health check requests, since they don't represent real user requests.
if isinstance(obj.rid, str) and HEALTH_CHECK_RID_PREFIX in obj.rid:
return
try:
# Get the log file path for the current time.
current_time = datetime.now()
hour_suffix = current_time.strftime("%Y%m%d_%H")
async with self._current_file_lock:
# Ensure correct file handler is open for current hour
self._ensure_file_handler(hour_suffix)
if self._current_file_handler is None:
return
metrics_data = self._format_output_data(obj, out_dict)
def write_file():
json.dump(metrics_data, self._current_file_handler)
self._current_file_handler.write("\n")
self._current_file_handler.flush()
await asyncio.to_thread(write_file)
except Exception as e:
logger.exception(f"Failed to write perf metrics to file: {e}")
class RequestMetricsExporterManager:
"""Manager class for creating and managing RequestMetricsExporter instances."""
def __init__(
self,
server_args: ServerArgs,
obj_skip_names: Optional[set[str]] = None,
out_skip_names: Optional[set[str]] = None,
):
self.server_args = server_args
self.obj_skip_names = obj_skip_names or set()
self.out_skip_names = out_skip_names or set()
self._exporters: List[RequestMetricsExporter] = []
self._create_exporters()
def _create_exporters(self) -> None:
"""Create and configure RequestMetricsExporter instances based on server args."""
# Create standard exporters
self._exporters.extend(
create_request_metrics_exporters(
self.server_args, self.obj_skip_names, self.out_skip_names
)
)
# Import additional RequestMetricsExporter from private fork if available; skip otherwise.
try:
from sglang.private.managers.request_metrics_exporter_factory import (
create_private_request_metrics_exporters,
)
self._exporters.extend(
create_private_request_metrics_exporters(
self.server_args, self.obj_skip_names, self.out_skip_names
)
)
except ImportError:
pass
def exporter_enabled(self) -> bool:
"""Return true if at least one RequestMetricsExporter is enabled."""
return len(self._exporters) > 0
async def write_record(self, obj, out_dict: dict) -> None:
"""Write a record using all configured exporters."""
for exporter in self._exporters:
await exporter.write_record(obj, out_dict)
def create_request_metrics_exporters(
server_args: ServerArgs,
obj_skip_names: Optional[set[str]] = None,
out_skip_names: Optional[set[str]] = None,
) -> List[RequestMetricsExporter]:
"""Create and configure `RequestMetricsExporter`s based on server args."""
metrics_exporters = []
if server_args.export_metrics_to_file:
metrics_exporters.append(
FileRequestMetricsExporter(server_args, obj_skip_names, out_skip_names)
)
return metrics_exporters
@@ -0,0 +1,150 @@
"""
Records startup latency breakdown by context using gauge metrics in seconds
"""
import logging
import time
from contextlib import contextmanager
from functools import wraps
from typing import Any, Callable, Dict, Generator, Optional
logger = logging.getLogger(__name__)
enable_startup_metrics = False
STARTUP_LATENCY_SECONDS = None
# Track maximum durations for each context
_max_durations: Dict[str, float] = {}
def enable_startup_timer():
"""Initialize startup latency metrics when metrics are enabled"""
# We need to import prometheus_client after setting the env variable `PROMETHEUS_MULTIPROC_DIR`
from prometheus_client import Gauge
global enable_startup_metrics, STARTUP_LATENCY_SECONDS
enable_startup_metrics = True
STARTUP_LATENCY_SECONDS = Gauge(
"sglang:startup_latency_breakdown_seconds_max",
"Startup latency breakdown in seconds by context, only records the maximum duration if the context is called multiple times.",
labelnames=["context"],
multiprocess_mode="mostrecent",
)
def set_startup_metric(context: str, value: float, should_log: bool = True):
"""Set the startup metric for a given context"""
if should_log:
logger.info(f"Setting startup metric: {context} took {value:.3f}s")
if not enable_startup_metrics:
return
current_max = _max_durations.get(context, 0.0)
if value > current_max:
_max_durations[context] = value
STARTUP_LATENCY_SECONDS.labels(context=context).set(value)
def reset_startup_timers():
"""Reset all recorded maximum durations. Useful for testing or reinitialization."""
global _max_durations
_max_durations.clear()
def get_max_duration(context: str) -> Optional[float]:
"""Get the maximum recorded duration for a context name."""
return _max_durations.get(context)
@contextmanager
def startup_timer(name: str, log_only: bool = False) -> Generator[None, None, None]:
"""
Context manager to measure startup latency for arbitrary code blocks.
Only records the maximum duration if the context is called multiple times.
Usage:
with startup_timer("model_loading"):
# model loading code
model = load_model()
with startup_timer("memory_allocation"):
# memory setup code
allocate_memory()
"""
start_time = time.monotonic()
try:
yield
finally:
duration_seconds = time.monotonic() - start_time
# Track the maximum duration for this context name
current_max = _max_durations.get(name, 0.0)
is_new_max = duration_seconds > current_max
if is_new_max:
_max_durations[name] = duration_seconds
# Only update Prometheus gauge if this is a new maximum
if enable_startup_metrics and not log_only:
STARTUP_LATENCY_SECONDS.labels(context=name).set(duration_seconds)
# Log with indication if this was a new max
logger.info(f"Startup timing: {name} took {duration_seconds:.3f}s")
def time_startup_latency(
func: Callable = None, name: Optional[str] = None, log_only: bool = False
) -> Callable[..., Any]:
"""
A decorator to measure startup context latency and record it in seconds.
Only records the maximum duration if the context is called multiple times.
Usage:
@time_startup_latency
def load_model():
# model loading code
@time_startup_latency(name="custom_init")
def initialize_something():
# initialization code
@time_startup_latency(name="debug_only", log_only=True)
def debug_function():
# This will only log, not record to Prometheus
"""
def measure(func: Callable[..., Any]) -> Callable[..., Any]:
nonlocal name
name = name or func.__name__
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.monotonic()
try:
result = func(*args, **kwargs)
return result
finally:
duration_seconds = time.monotonic() - start_time
# Track the maximum duration for this context name
current_max = _max_durations.get(name, 0.0)
is_new_max = duration_seconds > current_max
if is_new_max:
_max_durations[name] = duration_seconds
# Only update Prometheus gauge if this is a new maximum
if enable_startup_metrics and not log_only:
STARTUP_LATENCY_SECONDS.labels(context=name).set(
duration_seconds
)
# Log the timing
logger.info(f"Startup timing: {name} took {duration_seconds:.3f}s")
return wrapper
if func:
return measure(func)
else:
return measure
+807
View File
@@ -0,0 +1,807 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""package for sglang requests tracing"""
from __future__ import annotations
import logging
import os
import random
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional
from sglang.srt.utils import get_int_env_var
logger = logging.getLogger(__name__)
opentelemetry_imported = False
opentelemetry_initialized = False
_trace_context_propagator = None
tracer: Optional[trace.Tracer] = None
# Modules allowed to emit spans (from --trace-modules); None means no filtering.
global_trace_modules: Optional[List[str]] = None
TRACE_HEADERS = ["traceparent", "tracestate"]
try:
from opentelemetry import context, propagate, trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as GRPCSpanExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HTTPSpanExporter,
)
from opentelemetry.sdk.environment_variables import (
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
)
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider, id_generator
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
_trace_context_propagator = TraceContextTextMapPropagator()
opentelemetry_imported = True
except ImportError:
class id_generator:
class IdGenerator:
pass
logger.debug("opentelemetry package is not installed, tracing disabled")
def extract_trace_headers(headers: Mapping[str, str]) -> Optional[Dict]:
return {h: headers[h] for h in TRACE_HEADERS if h in headers}
def get_global_trace_level() -> int:
from sglang.srt.runtime_context import get_resources
resources = get_resources()
if resources.trace_level is None:
resources.trace_level = get_int_env_var("SGLANG_TRACE_LEVEL", 3)
return resources.trace_level
def set_global_trace_level(level: int):
from sglang.srt.runtime_context import get_resources
get_resources().trace_level = level
@dataclass
class TraceThreadInfo:
host_id: str
pid: int
thread_label: str
tp_rank: int
dp_rank: int
pp_rank: int
@dataclass
class TraceEvent:
event_name: str
ts: int
attrs: Dict[str, Any]
@dataclass
class TraceSliceContext:
slice_name: str
start_time_ns: int
end_time_ns: Optional[int] = None
span: Optional[trace.span.Span] = None
level: int = 1
attrs: Optional[Dict[str, Any]] = None
events: Optional[List[TraceEvent]] = None
@dataclass
class TraceThreadContext:
thread_info: TraceThreadInfo
cur_slice_stack: Optional[List[TraceSliceContext]] = None
thread_span: Optional[trace.span.Span] = None
class TraceCustomIdGenerator(id_generator.IdGenerator):
"""
The default IdGenerator may produce duplicate trace IDs across multiple TP scheduler processes,
hence a custom IdGenerator is implemented.
"""
def __init__(self):
super().__init__()
self.local_random = random.Random()
self.local_random.seed(time.time())
def generate_trace_id(self) -> int:
return self.local_random.getrandbits(64)
def generate_span_id(self) -> int:
return self.local_random.getrandbits(64)
# global variables
threads_info: Dict[int, TraceThreadInfo] = {}
get_cur_time_ns = lambda: int(time.time() * 1e9)
if hasattr(time, "time_ns"):
get_cur_time_ns = lambda: int(time.time_ns())
def _get_host_id() -> str:
"""
In distributed tracing systems, obtain a unique node identifier
and inject it into all subsequently generated spans
to prevent PID conflicts between threads on different nodes.
"""
if os.path.exists("/etc/machine-id"):
try:
with open("/etc/machine-id", "r") as f:
return f.read().strip()
except:
pass
mac = uuid.getnode()
if mac != 0:
return uuid.UUID(int=mac).hex
return "unknown"
# Should be called by each tracked process.
def process_tracing_init(
otlp_endpoint, server_name, trace_modules: Optional[str] = None
):
global opentelemetry_initialized
global get_cur_time_ns
global tracer
global global_trace_modules
if trace_modules is not None:
global_trace_modules = [
module.strip() for module in trace_modules.split(",") if module.strip()
]
if not opentelemetry_imported:
opentelemetry_initialized = False
raise RuntimeError(
"opentelemetry package is not installed!!! Please not enable tracing or install opentelemetry"
)
try:
resource = Resource.create(
attributes={
SERVICE_NAME: server_name,
}
)
tracer_provider = TracerProvider(
resource=resource, id_generator=TraceCustomIdGenerator()
)
schedule_delay_millis = get_int_env_var(
"SGLANG_OTLP_EXPORTER_SCHEDULE_DELAY_MILLIS", 500
)
max_export_batch_size = get_int_env_var(
"SGLANG_OTLP_EXPORTER_MAX_EXPORT_BATCH_SIZE", 64
)
processor = BatchSpanProcessor(
span_exporter=get_otlp_span_exporter(otlp_endpoint),
schedule_delay_millis=schedule_delay_millis,
max_export_batch_size=max_export_batch_size,
)
tracer_provider.add_span_processor(processor)
trace.set_tracer_provider(tracer_provider)
except Exception as e:
opentelemetry_initialized = False
raise RuntimeError(
f"initialize opentelemetry error:{e}. Please set correct otlp endpoint."
)
opentelemetry_initialized = True
tracer = trace.get_tracer("sglang server")
def get_global_tracing_enabled():
return opentelemetry_initialized
def get_otlp_span_exporter(endpoint):
protocol = os.environ.get(OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "grpc")
supported_protocols = {"grpc", "http/protobuf"}
if protocol not in supported_protocols:
raise ValueError(
f"Unsupported OTLP protocol '{protocol}' configured. "
f"Supported protocols are: {', '.join(sorted(supported_protocols))}"
)
if protocol == "grpc":
return GRPCSpanExporter(endpoint=endpoint, insecure=True)
elif protocol == "http/protobuf":
return HTTPSpanExporter(endpoint=endpoint)
# Should be called by each tracked thread.
def trace_set_thread_info(
thread_label: str,
tp_rank: Optional[int] = None,
dp_rank: Optional[int] = None,
pp_rank: Optional[int] = None,
):
if not opentelemetry_initialized:
return
pid = threading.get_native_id()
if pid in threads_info:
return
threads_info[pid] = TraceThreadInfo(
host_id=_get_host_id(),
pid=pid,
thread_label=thread_label,
tp_rank=tp_rank,
dp_rank=dp_rank,
pp_rank=pp_rank,
)
class TraceReqContext:
def __init__(
self,
rid,
bootstrap_room=None,
role="unified",
module_name="",
external_trace_header: Optional[Dict[str, str]] = None,
):
self.rid: str = str(rid)
self.trace_level = get_global_trace_level()
self.tracing_enable: bool = opentelemetry_initialized and self.trace_level > 0
# Filter by --trace-modules only for explicitly named modules; contexts
# created with the default empty module_name are always traced.
if (
module_name
and global_trace_modules is not None
and module_name not in global_trace_modules
):
self.tracing_enable = False
if not self.tracing_enable:
return
self.start_time_ns: Optional[int] = None
self.thread_context: Optional[TraceThreadContext] = None
self.bootstrap_room: Optional[int] = bootstrap_room
self.role: str = role
self.module_name = module_name
# Indicates whether this instance is a replica from the main process.
# When True, root_span is None and only root_span_context is preserved.
self.is_copy: bool = False
self.root_span: Optional[trace.span.Span] = None
self.root_span_context: Optional[context.Context] = None
# Record the most recently completed span as the previous span for the next span to be created.
self.last_span_context: Optional[trace.span.SpanContext] = None
self.external_trace_header: Optional[Dict[str, str]] = external_trace_header
self.events_cache: List[TraceEvent] = []
self.pid: int = threading.get_native_id()
def is_tracing_enabled(self) -> bool:
return self.tracing_enable
def __create_thread_context(self, ts: int):
if self.pid not in threads_info:
trace_set_thread_info("unknown")
thread_info = threads_info[self.pid]
thread_context = TraceThreadContext(
thread_info=thread_info,
cur_slice_stack=[],
)
thread_name = f"{thread_info.thread_label}"
if thread_info.tp_rank is not None:
thread_name += f" [TP {thread_info.tp_rank}] "
if thread_info.pp_rank is not None:
thread_name += f" [PP {thread_info.pp_rank}] "
if thread_info.dp_rank is not None:
thread_name += f" [DP {thread_info.dp_rank}] "
thread_name += f"(host:{thread_info.host_id[:8]} | pid:{self.pid})"
thread_context.thread_span = tracer.start_span(
name=thread_name,
start_time=ts,
context=self.root_span_context,
)
rank_attrs = {}
if thread_info.tp_rank is not None:
rank_attrs["tp_rank"] = thread_info.tp_rank
if thread_info.pp_rank is not None:
rank_attrs["pp_rank"] = thread_info.pp_rank
if thread_info.dp_rank is not None:
rank_attrs["dp_rank"] = thread_info.dp_rank
if rank_attrs:
thread_context.thread_span.set_attributes(rank_attrs)
thread_context.thread_span.set_attributes(
{
"host_id": thread_info.host_id,
"pid": thread_info.pid,
"thread_label": thread_info.thread_label,
}
)
return thread_context
def __getstate__(self) -> Optional[Dict[str, Any]]:
if not self.tracing_enable:
return {"tracing_enable": False}
if not self.root_span_context:
return {"tracing_enable": False}
state = {
"tracing_enable": self.tracing_enable,
"rid": self.rid,
"bootstrap_room": self.bootstrap_room,
"start_time_ns": self.start_time_ns,
"role": self.role,
"trace_level": self.trace_level,
"module_name": self.module_name,
"is_copy": self.is_copy,
"pid": self.pid,
"thread_context": None,
"root_span": None,
"last_span_context": None,
}
carrier: dict[str, str] = {}
propagate.inject(carrier, self.root_span_context)
state["root_span_context"] = carrier
prev_span_context = self.last_span_context
if self.thread_context and self.thread_context.cur_slice_stack:
cur_slice = self.thread_context.cur_slice_stack[0]
if cur_slice.span:
prev_span_context = cur_slice.span.get_span_context()
if prev_span_context:
state["last_span_context"] = {
"span_id": prev_span_context.span_id,
"trace_id": prev_span_context.trace_id,
}
return state
def __setstate__(self, state: Dict[str, Any]):
self.__dict__.update(state)
if not opentelemetry_initialized:
self.tracing_enable = False
if not self.tracing_enable:
return
self.is_copy = True
self.pid = threading.get_native_id()
self.root_span_context = propagate.extract(self.root_span_context)
if self.last_span_context:
self.last_span_context = trace.span.SpanContext(
trace_id=self.last_span_context["trace_id"],
span_id=self.last_span_context["span_id"],
is_remote=True,
)
self.events_cache = []
def copy_for_thread(self) -> TraceReqContext:
"""
Create a copy of this context for use in another thread.
The copy shares the same root_span_context but has its own thread_context.
This is useful for propagating trace context across threads (e.g., worker threads).
Usage:
# Sender (main thread)
trace_ctx_copy = trace_ctx.copy_for_thread()
queue.put(TransferKVChunk(..., trace_ctx=trace_ctx_copy))
# Receiver (worker thread)
kv_chunk = queue.get()
kv_chunk.trace_ctx.rebuild_thread_context()
"""
# Fast path: not tracing
if not self.tracing_enable or not self.root_span_context:
return TraceNullContext()
# Extract prev_span_context from current thread state
prev_span_context = self.last_span_context
if self.thread_context and self.thread_context.cur_slice_stack:
cur_slice = self.thread_context.cur_slice_stack[0]
if cur_slice.span:
prev_span_context = cur_slice.span.get_span_context()
# Create new instance with shared state
copied = TraceReqContext.__new__(TraceReqContext)
copied.tracing_enable = self.tracing_enable
copied.rid = self.rid
copied.bootstrap_room = self.bootstrap_room
copied.start_time_ns = self.start_time_ns
copied.role = self.role
copied.trace_level = self.trace_level
copied.module_name = self.module_name
copied.is_copy = True # Mark as copy
copied.pid = self.pid
# thread_context is None, will be rebuilt via rebuild_thread_context()
copied.thread_context = None
copied.root_span = None
# Share root_span_context (already a context, no need to serialize)
copied.root_span_context = self.root_span_context
# Set prev_span_context for linking spans
if prev_span_context:
copied.last_span_context = trace.span.SpanContext(
trace_id=prev_span_context.trace_id,
span_id=prev_span_context.span_id,
is_remote=True,
)
else:
copied.last_span_context = None
copied.events_cache = []
return copied
def rebuild_thread_context(self, ts: Optional[int] = None):
if not self.tracing_enable:
return
ts = ts or get_cur_time_ns()
self.thread_context = self.__create_thread_context(ts)
def trace_req_start(
self,
ts: Optional[int] = None,
):
if not self.tracing_enable:
return
ts = ts or get_cur_time_ns()
# create req context and root span
self.start_time_ns = ts
external_trace_context = _trace_context_propagator.extract(
self.external_trace_header or {}
)
# Drop the worker_id added by MultiTokenizer
orig_rid = self.rid.split("_")[-1]
role = "" if self.role == "unified" else self.role
attrs = {"rid": orig_rid, "module": f"sglang::{self.module_name}"}
if self.bootstrap_room:
attrs["bootstrap_room"] = str(hex(self.bootstrap_room))
root_span = tracer.start_span(
name=f"{role} Req {orig_rid[:8]}",
start_time=ts,
context=external_trace_context,
attributes=attrs,
)
self.root_span = root_span
self.root_span_context = trace.set_span_in_context(root_span)
# create thread context and thread span
self.thread_context = self.__create_thread_context(ts)
def trace_req_finish(
self, ts: Optional[int] = None, attrs: Optional[Dict[str, Any]] = None
):
if not self.tracing_enable:
return
if not self.root_span:
return
ts = ts or get_cur_time_ns()
# End all unclosed thread spans.
self.abort()
if attrs:
self.root_span.set_attributes(attrs)
self.root_span.end(end_time=ts)
self.root_span = None
def __check_fast_return(self, level=None):
if not self.tracing_enable:
return True
if not self.thread_context:
return True
if level and level > self.trace_level:
return True
return False
def trace_slice_start(
self,
name: str,
level: int,
ts: Optional[int] = None,
):
if self.__check_fast_return(level):
return
ts = ts or get_cur_time_ns()
cur_slice = TraceSliceContext(
slice_name=name,
start_time_ns=ts,
level=level,
attrs={},
events=[],
)
parent_span = self.thread_context.thread_span
prev_span_context = None
if not self.thread_context.cur_slice_stack:
if self.last_span_context:
prev_span_context = self.last_span_context
else:
parent_span = self.thread_context.cur_slice_stack[-1].span
parent_span_context = trace.set_span_in_context(parent_span)
span = tracer.start_span(
name=cur_slice.slice_name,
start_time=cur_slice.start_time_ns,
context=parent_span_context,
)
cur_slice.span = span
if prev_span_context:
span.add_link(prev_span_context)
self.thread_context.cur_slice_stack.append(cur_slice)
def trace_slice_end(
self,
name: str,
level: int,
ts: Optional[int] = None,
attrs: Optional[Dict[str, Any]] = None,
thread_finish_flag: bool = False,
):
if self.__check_fast_return(level):
return
if not self.thread_context.cur_slice_stack:
logger.warning(
f"No matching with the SLICE_START event {name} is required."
)
return
cur_slice = self.thread_context.cur_slice_stack[-1]
ts = ts or get_cur_time_ns()
# check if slice_name matching and level matching
# unlikely path, excepting error API usage
if cur_slice.slice_name != name or cur_slice.level != level:
logger.warning(
f"Slice name mismatch: {name} != {cur_slice.slice_name} or level mismatch: {level} != {cur_slice.level}"
)
self.thread_context.cur_slice_stack.pop()
return
span = cur_slice.span
if attrs:
span.set_attributes(attrs)
if self.events_cache:
new_events_cache = []
for event in self.events_cache:
if event.ts >= cur_slice.start_time_ns and event.ts < ts:
span.add_event(
name=event.event_name,
timestamp=event.ts,
attributes=event.attrs,
)
else:
new_events_cache.append(event)
self.events_cache = new_events_cache
span.end(end_time=ts)
self.thread_context.cur_slice_stack.pop()
# only for first level slice
if not self.thread_context.cur_slice_stack:
self.last_span_context = span.get_span_context()
if thread_finish_flag:
self.abort(ts)
def trace_slice(
self,
slice: TraceSliceContext,
thread_finish_flag: bool = False,
):
if self.__check_fast_return(slice.level):
return
parent_span = self.thread_context.thread_span
prev_span_context = None
if not self.thread_context.cur_slice_stack:
if self.last_span_context:
prev_span_context = self.last_span_context
else:
parent_span = self.thread_context.cur_slice_stack[-1].span
parent_span_context = trace.set_span_in_context(parent_span)
span = tracer.start_span(
name=slice.slice_name,
start_time=slice.start_time_ns,
context=parent_span_context,
)
if prev_span_context:
span.add_link(prev_span_context)
if slice.attrs:
span.set_attributes(slice.attrs)
if slice.events:
for event in slice.events:
span.add_event(
name=event.event_name, timestamp=event.ts, attributes=event.attrs
)
if self.events_cache:
new_events_cache = []
for event in self.events_cache:
if event.ts >= slice.start_time_ns and event.ts < slice.end_time_ns:
span.add_event(
name=event.event_name,
timestamp=event.ts,
attributes=event.attrs,
)
else:
new_events_cache.append(event)
self.events_cache = new_events_cache
span.end(end_time=slice.end_time_ns)
# only for first level slice
if not self.thread_context.cur_slice_stack:
self.last_span_context = span.get_span_context()
if thread_finish_flag:
self.abort(slice.end_time_ns)
# Add event to the current slice on the same thread with the same rid.
def trace_event(
self,
name: str,
level: int,
ts: Optional[int] = None,
attrs: Dict[str, Any] = None,
):
if self.__check_fast_return(level):
return
ts = ts or get_cur_time_ns()
if attrs is None:
attrs = {}
self.events_cache.append(TraceEvent(name, ts, attrs))
def trace_set_root_attrs(self, attrs: Dict[str, Any]):
if not self.tracing_enable:
return
if self.root_span:
self.root_span.set_attributes(attrs)
def trace_set_thread_attrs(self, attrs: Dict[str, Any]):
if self.__check_fast_return():
return
if self.thread_context.thread_span:
self.thread_context.thread_span.set_attributes(attrs)
def abort(self, ts=None, abort_info: Optional[Dict] = None):
if self.__check_fast_return():
return
# close all slice spans (unlikely, except error API usage)
ts = ts or get_cur_time_ns()
while len(self.thread_context.cur_slice_stack) > 0:
if self.thread_context.cur_slice_stack[-1].span:
self.thread_context.cur_slice_stack[-1].span.end(end_time=ts)
self.thread_context.cur_slice_stack.pop()
# set abort info into thread span
if self.thread_context.thread_span:
if abort_info:
from sglang.srt.managers.schedule_batch import BaseFinishReason
if isinstance(abort_info, BaseFinishReason):
abort_info = abort_info.to_json()
self.thread_context.thread_span.set_status(Status(StatusCode.ERROR))
self.thread_context.thread_span.set_attributes(abort_info)
if self.events_cache:
for event in self.events_cache:
self.thread_context.thread_span.add_event(
name=event.event_name,
timestamp=event.ts,
attributes=event.attrs,
)
self.events_cache = []
self.thread_context.thread_span.end(end_time=ts)
self.thread_context = None
def __del__(self):
self.abort(abort_info={"reason": "have unclosed span, auto closed"})
@dataclass
class TraceNullContext:
tracing_enable: bool = False
def __getattr__(self, name):
return self
def __call__(self, *args, **kwargs):
return self
class SpanAttributes:
# Attribute names copied from here to avoid version conflicts:
# https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md
GEN_AI_USAGE_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens"
GEN_AI_USAGE_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens"
GEN_AI_USAGE_CACHED_TOKENS = "gen_ai.usage.cached_tokens"
GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"
GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k"
GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GEN_AI_REQUEST_ID = "gen_ai.request.id"
GEN_AI_REQUEST_N = "gen_ai.request.n"
GEN_AI_LATENCY_TIME_IN_QUEUE = "gen_ai.latency.time_in_queue"
GEN_AI_LATENCY_TIME_TO_FIRST_TOKEN = "gen_ai.latency.time_to_first_token"
GEN_AI_LATENCY_E2E = "gen_ai.latency.e2e"
GEN_AI_LATENCY_TIME_IN_MODEL_PREFILL = "gen_ai.latency.time_in_model_prefill"
GEN_AI_LATENCY_TIME_IN_MODEL_DECODE = "gen_ai.latency.time_in_model_decode"
GEN_AI_LATENCY_TIME_IN_MODEL_INFERENCE = "gen_ai.latency.time_in_model_inference"
+56
View File
@@ -0,0 +1,56 @@
# Copyright 2023-2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Utilities for Prometheus Metrics."""
import math
from typing import List
def two_sides_exponential_buckets(
middle: float, base: float, count: int
) -> List[float]:
buckets = []
half_count = math.ceil(count / 2)
distance = 1
buckets.append(middle)
for i in range(half_count):
distance *= base
buckets.append(middle + distance)
buckets.append(max(0, middle - distance))
return sorted(set(buckets))
def generate_buckets(
buckets_rule: List[str], default_buckets: List[float]
) -> List[float]:
if not buckets_rule:
buckets_rule = ["default"]
assert len(buckets_rule) > 0
rule = buckets_rule[0]
if rule == "tse":
middle, base, count = buckets_rule[1:]
assert float(base) > 1.0, "Base must be greater than 1.0"
return two_sides_exponential_buckets(float(middle), float(base), int(count))
if rule == "default":
return sorted(set(default_buckets))
assert rule == "custom"
return sorted(set([float(x) for x in buckets_rule[1:]]))
def exponential_buckets(start: float, width: float, length: int) -> List[float]:
buckets = []
for i in range(length):
buckets.append(start * (width**i))
return buckets