[Feat] rust-server: Report weight versions and media token counts in generate meta_info (#46205)

Co-authored-by: metamergebot <metamergebot@users.noreply.github.com>
Co-authored-by: shodoco <shodoco@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-24 17:50:06 -07:00
1 parent 6563312956
commit c5e734e437
41 files changed
+3344 -214

No files matched your search

+4
View File
@@ -205,6 +205,10 @@ message GenerateMetaInfo {
optional string weight_version = 16;
// Omitted until terminal output; reported spans are always nonempty.
repeated WeightVersionSpan weight_versions = 17;
// Media prompt tokens per modality, on every frame; omitted when zero.
optional uint32 image_tokens = 18;
optional uint32 audio_tokens = 19;
optional uint32 video_tokens = 20;
}
message WeightVersionSpan {
+15
View File
@@ -374,6 +374,9 @@ class GenerateMetaInfo(msgspec.Struct, kw_only=True, frozen=True):
output_token_logprobs_length: Optional[int] = None
weight_version: Optional[str] = None
weight_versions: List[WeightVersionSpan] = []
image_tokens: Optional[int] = None
audio_tokens: Optional[int] = None
video_tokens: Optional[int] = None
@classmethod
def from_json_value(cls, value: Any) -> "GenerateMetaInfo":
@@ -1776,6 +1779,12 @@ def decode_GenerateMetaInfo(value: Any) -> GenerateMetaInfo:
kw["weight_version"] = None if x is None else _expect_str(x, expected="a string")
elif key == "weight_versions":
kw["weight_versions"] = [] if x is None else [decode_WeightVersionSpan(e) for e in _expect_seq(x, expected="a sequence")]
elif key == "image_tokens":
kw["image_tokens"] = None if x is None else _expect_uint(x, expected="u32")
elif key == "audio_tokens":
kw["audio_tokens"] = None if x is None else _expect_uint(x, expected="u32")
elif key == "video_tokens":
kw["video_tokens"] = None if x is None else _expect_uint(x, expected="u32")
return GenerateMetaInfo(**kw)
@@ -1809,6 +1818,12 @@ def encode_GenerateMetaInfo(v: GenerateMetaInfo) -> Dict[str, Any]:
d["weight_version"] = v.weight_version
if v.weight_versions:
d["weight_versions"] = [encode_WeightVersionSpan(e) for e in v.weight_versions]
if v.image_tokens is not None:
d["image_tokens"] = v.image_tokens
if v.audio_tokens is not None:
d["audio_tokens"] = v.audio_tokens
if v.video_tokens is not None:
d["video_tokens"] = v.video_tokens
return d
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
@@ -38,6 +39,7 @@ from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.srt.utils.weight_versions import compute_weight_version_spans
if TYPE_CHECKING:
from sglang.srt.observability.req_time_stats import SchedulerReqTimeStats
from sglang.srt.rust_server.server import RustServer
@@ -47,6 +49,20 @@ logger = logging.getLogger(__name__)
DEFAULT_FORCE_STREAM_INTERVAL = envs.SGLANG_FORCE_STREAM_INTERVAL.get()
def _scheduler_decode_latency(time_stats: SchedulerReqTimeStats) -> Optional[float]:
"""A finished non-streaming request's decode latency as Python's
TokenizerManager measures it: from the scheduler's prefill-finished stamp
(`_get_scheduler_first_token_time`, which has the stamp only when metrics
are on) to now, its terminal output (`get_decode_latency`). None where it
falls back to its own first-output time, which the Rust frontend
measures the same way."""
stamp = time_stats.prefill_finished_time
now = time.perf_counter()
if time_stats.enable_metrics and 0.0 < stamp <= now:
return now - stamp
return None
@dataclass(kw_only=True, slots=True)
class SchedulerOutputStreamer:
has_additional_customized_info: ClassVar[bool] = False
@@ -214,7 +230,7 @@ class SchedulerOutputStreamer:
)
if payload is not None:
if self.rust_server is not None:
self.rust_server.push_generation(payload)
self.rust_server.push_generation(payload, acc.decode_latencies)
else:
self.send_to_detokenizer.send_output(payload)
@@ -390,6 +406,9 @@ class _GenerationStreamAccumulator:
# output tokens itself and never consumes the scheduler's incremental-detok
# offsets (decode_ids / read_offset), so that per-step bookkeeping is skipped.
rust_server_mode: bool = False
# Rust server mode: by row, the decode latency of a finished non-streaming
# request, whose first token the Rust frontend does not see.
decode_latencies: dict = field(default_factory=dict)
def __post_init__(self) -> None:
if self.return_hidden_states:
@@ -527,6 +546,10 @@ class _GenerationStreamAccumulator:
num_output_tokens=len(output_ids_),
)
)
if self.rust_server_mode and not req.stream:
latency = _scheduler_decode_latency(req.time_stats)
if latency is not None:
self.decode_latencies[len(self.rids) - 1] = latency
else:
self.weight_versions.append(None)
@@ -137,6 +137,7 @@ from sglang.srt.observability.metrics_collector import (
STAT_LOGGER_ROLE_TOKENIZER,
TokenizerMetricsCollector,
resolve_collector_class,
tokenizer_metric_labels,
)
from sglang.srt.observability.req_time_stats import (
APIServerReqTimeStats,
@@ -834,23 +835,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
def init_metric_collector_watchdog(self):
# Metrics
if self.enable_metrics:
engine_type = DisaggregationMode.to_engine_type(
get_disagg().disaggregation_mode
)
labels = {
"model_name": get_serving().served_model_name,
"engine_type": engine_type,
}
if self.enable_priority_scheduling:
labels["priority"] = ""
if get_observability().tokenizer_metrics_allowed_custom_labels:
for (
label
) in get_observability().tokenizer_metrics_allowed_custom_labels:
labels[label] = ""
if get_observability().extra_metric_labels:
labels.update(get_observability().extra_metric_labels)
labels = tokenizer_metric_labels()
tokenizer_collector_cls = resolve_collector_class(
STAT_LOGGER_ROLE_TOKENIZER,
TokenizerMetricsCollector,
@@ -1775,6 +1775,24 @@ class SchedulerMetricsCollector(_StatLoggerDIMixin):
)
def tokenizer_metric_labels() -> Dict[str, str]:
"""The labels on every ``TokenizerMetricsCollector`` metric. The priority
and custom labels start empty; each request fills them in."""
labels = {
"model_name": get_serving().served_model_name,
"engine_type": DisaggregationMode.to_engine_type(
get_disagg().disaggregation_mode
),
}
if get_schedule().enable_priority_scheduling:
labels["priority"] = ""
for label in get_observability().tokenizer_metrics_allowed_custom_labels or ():
labels[label] = ""
if get_observability().extra_metric_labels:
labels.update(get_observability().extra_metric_labels)
return labels
class TokenizerMetricsCollector(_StatLoggerDIMixin):
def __init__(
self,
+28 -5
View File
@@ -17,6 +17,8 @@ from sglang.srt.entrypoints.v1_loads import (
from sglang.srt.environ import envs
from sglang.srt.managers.load_snapshot import ShmLoadSnapshotWriter
from sglang.srt.managers.utils import compute_num_reserved_tokens
from sglang.srt.observability.metrics_collector import tokenizer_metric_labels
from sglang.srt.observability.utils import generate_buckets
from sglang.srt.runtime_context import (
get_device,
get_disagg,
@@ -196,13 +198,34 @@ def _build_server_args(
load_snapshot_dp_rank=shm_writer.dp_rank if shm_writer else 0,
accelerator=_accelerator_name(),
num_accelerators=_num_accelerators_per_dp_rank(),
metrics=(
_build_metrics_config(ext) if get_observability().enable_metrics else None
),
)
def _build_metrics_config(ext: ModuleType):
"""What the Rust server needs to record ``TokenizerMetricsCollector``'s
families as Python does, and where every process's samples live."""
obs = get_observability()
def token_buckets(rule: Optional[List[str]]) -> Optional[List[float]]:
# The default rule keeps the Rust side's copy of Python's defaults.
if not rule or rule[0] == "default":
return None
return generate_buckets(rule, [])
return ext.MetricsConfig(
# Every process writes its prometheus_client samples under this
# directory (set before the schedulers start); `/metrics` merges them.
metrics=(
ext.MetricsConfig(multiproc_dir=os.environ["PROMETHEUS_MULTIPROC_DIR"])
if get_observability().enable_metrics
else None
),
multiproc_dir=os.environ["PROMETHEUS_MULTIPROC_DIR"],
labels=tokenizer_metric_labels(),
priority_label=get_schedule().enable_priority_scheduling,
bucket_time_to_first_token=obs.bucket_time_to_first_token,
bucket_inter_token_latency=obs.bucket_inter_token_latency,
bucket_e2e_request_latency=obs.bucket_e2e_request_latency,
bucket_prompt_tokens=token_buckets(obs.prompt_tokens_buckets),
bucket_generation_tokens=token_buckets(obs.generation_tokens_buckets),
)
+72 -13
View File
@@ -51,6 +51,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# The extras families' header columns (`BatchHeader.out_lp_lens` through
# `hidden_poslens`). A frame without extras that sends a later column pads
# them empty; `push_generation` asserts the count whenever it sends extras.
_EXTRAS_HEADER_COLS = 12
class RustServer:
"""Owns the embedded multi-threaded Rust server (``sglang_server.Server``).
@@ -89,6 +94,12 @@ class RustServer:
clamps to what the context window leaves after the prompt."""
return SamplingParams().max_new_tokens
@classmethod
def _server_kwargs(cls, scheduler: Scheduler) -> Dict[str, Any]:
"""Keyword arguments a model package's own ``Server`` class takes
beyond the shared ones (see ``_load_extension``)."""
return {}
@classmethod
def _load_extension(cls) -> ModuleType:
from sglang.srt.rust_extensions import load_rust_extension
@@ -197,6 +208,7 @@ class RustServer:
# None runs unpinned; otherwise the list carries the pinning decision.
cores=server_cores,
port_offset=local_dp_rank,
**cls._server_kwargs(scheduler),
)
instance = cls(server, http_port=listen_port)
@@ -389,21 +401,33 @@ class RustServer:
Python's ``TokenizerManager._handle_abort_req`` builds from the same
``AbortReq``: a one-request batch carrying no new tokens and the abort
finish reason. The counts are the request's own, so the frame does not
reset the ones a stream already reported."""
reset the ones a stream already reported. The frame is marked as ended
outside the stream: like ``_handle_abort_req``, the frontend records no
request metrics for it."""
finish_reason = abort.finished_reason or {
"type": "abort",
"message": abort.abort_message or "Abort in waiting queue",
}
header = msgspec.msgpack.encode(
[
[abort.rid],
[finish_reason],
[len(req.origin_input_ids)],
[0],
[req.cached_tokens],
[req.reasoning_tokens],
]
)
header_cols = [
[abort.rid],
[finish_reason],
[len(req.origin_input_ids)],
[0],
[req.cached_tokens],
[req.reasoning_tokens],
]
header_cols += [[]] * _EXTRAS_HEADER_COLS
header_cols += [
[abort.weight_versions] if abort.weight_versions is not None else [],
[], # image_tokens
[], # audio_tokens
[], # video_tokens
[], # decode_latencies
# Ended outside the output stream: `_handle_abort_req` records no
# request metrics for it.
[True],
]
header = msgspec.msgpack.encode(header_cols)
if not self.server.push_decode_result_batch(header, [b""]):
logger.warning(
"Rust egress closed; dropped abort of %s during shutdown", abort.rid
@@ -413,7 +437,9 @@ class RustServer:
"""Acknowledge the health probes skipped while the scheduler was busy."""
self.server.push_health_ack()
def push_generation(self, payload: BatchTokenIDOutput) -> None:
def push_generation(
self, payload: BatchTokenIDOutput, decode_latencies: Dict[int, float]
) -> None:
"""Egress redirect for generation output (replaces the zmq detokenizer).
Push the WHOLE batch into the Rust egress ring as one frame (-> detokenizer
@@ -425,7 +451,10 @@ class RustServer:
cached_tokens, reasoning_tokens``) plus
the shape metadata for the optional families (``*_lens`` element counts
for the flat logprob columns, ``*_reqlens``/``*_poslens`` for the ragged
and hidden ones).
and hidden ones), then the finished requests' weight-version
spans, a multimodal model's media token counts, and
``decode_latencies`` (by row: a finished non-streaming request's
decode latency in seconds).
- ``data``: the raw little-endian numeric buffer: the token ids
first, as the 8-byte ``array("q")`` bytes the scheduler already
holds, then every logprob / hidden column as 4-byte elements
@@ -563,9 +592,39 @@ class RustServer:
for i in range(batch_size):
accept(i)
extras_start = len(header_cols)
for extra in extras:
header_cols += extra.header_cols()
data_cols += extra.data_cols()
# The padding below must line up with these columns.
assert len(header_cols) - extras_start == _EXTRAS_HEADER_COLS, (
"_EXTRAS_HEADER_COLS no longer matches the extras families"
)
# After the extras: the finished requests' weight-version spans (None
# for the others), a multimodal model's media token counts and the
# finished requests' decode latencies, each empty when nothing in the
# batch carries it. Only the last is built here, for finished rows; the
# header stops at its last non-empty column.
latencies = []
if decode_latencies:
latencies = [None] * len(rids)
for row, latency in decode_latencies.items():
latencies[row] = latency
media = self._multimodal_enabled
trailing = [
payload.weight_versions or [],
(payload.image_tokens or []) if media else [],
(payload.audio_tokens or []) if media else [],
(payload.video_tokens or []) if media else [],
latencies,
]
while trailing and not trailing[-1]:
trailing.pop()
if trailing:
if not has_extra:
header_cols += [[]] * _EXTRAS_HEADER_COLS
header_cols += trailing
header = msgspec.msgpack.encode(header_cols)
# Pass the raw column list; the Rust side concatenates it into the frame
@@ -590,6 +590,13 @@ pub struct GenerateMetaInfo {
/// Omitted until terminal output; reported spans are always nonempty.
#[prost(message, repeated, tag = "17")]
pub weight_versions: ::prost::alloc::vec::Vec<WeightVersionSpan>,
/// Media prompt tokens per modality, on every frame; omitted when zero.
#[prost(uint32, optional, tag = "18")]
pub image_tokens: ::core::option::Option<u32>,
#[prost(uint32, optional, tag = "19")]
pub audio_tokens: ::core::option::Option<u32>,
#[prost(uint32, optional, tag = "20")]
pub video_tokens: ::core::option::Option<u32>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct WeightVersionSpan {
@@ -2618,6 +2618,15 @@ impl ::serde::Serialize for GenerateMetaInfo {
&self.weight_versions,
)?;
}
if let Some(v) = &self.image_tokens {
::serde::ser::SerializeMap::serialize_entry(&mut map, "image_tokens", v)?;
}
if let Some(v) = &self.audio_tokens {
::serde::ser::SerializeMap::serialize_entry(&mut map, "audio_tokens", v)?;
}
if let Some(v) = &self.video_tokens {
::serde::ser::SerializeMap::serialize_entry(&mut map, "video_tokens", v)?;
}
::serde::ser::SerializeMap::end(map)
}
}
@@ -2651,6 +2660,9 @@ impl<'de> ::serde::Deserialize<'de> for GenerateMetaInfo {
let mut f_output_token_logprobs_length = None;
let mut f_weight_version = None;
let mut f_weight_versions = None;
let mut f_image_tokens = None;
let mut f_audio_tokens = None;
let mut f_video_tokens = None;
while let Some(key) = map.next_key::<::std::borrow::Cow<'_, str>>()? {
match key.as_ref() {
"id" => {
@@ -2794,6 +2806,24 @@ impl<'de> ::serde::Deserialize<'de> for GenerateMetaInfo {
.unwrap_or_default(),
);
}
"image_tokens" => {
if f_image_tokens.is_some() {
return Err(::serde::de::Error::duplicate_field("image_tokens"));
}
f_image_tokens = Some(map.next_value::<Option<u32>>()?);
}
"audio_tokens" => {
if f_audio_tokens.is_some() {
return Err(::serde::de::Error::duplicate_field("audio_tokens"));
}
f_audio_tokens = Some(map.next_value::<Option<u32>>()?);
}
"video_tokens" => {
if f_video_tokens.is_some() {
return Err(::serde::de::Error::duplicate_field("video_tokens"));
}
f_video_tokens = Some(map.next_value::<Option<u32>>()?);
}
_ => {
map.next_value::<::serde::de::IgnoredAny>()?;
}
@@ -2817,6 +2847,9 @@ impl<'de> ::serde::Deserialize<'de> for GenerateMetaInfo {
output_token_logprobs_length: f_output_token_logprobs_length.flatten(),
weight_version: f_weight_version.flatten(),
weight_versions: f_weight_versions.unwrap_or_default(),
image_tokens: f_image_tokens.flatten(),
audio_tokens: f_audio_tokens.flatten(),
video_tokens: f_video_tokens.flatten(),
})
}
}
+17 -3
View File
@@ -238,7 +238,7 @@
},
{
"type": "GenerateResponse",
"name": "terminal batch frame with every logprob family, null rows, hidden states, e2e_latency, cache and reasoning counts, index",
"name": "terminal batch frame with every logprob family, null rows, hidden states, e2e_latency, cache and reasoning counts, weight versions, media counts, index",
"input": {
"text": "ok",
"meta_info": {
@@ -305,14 +305,28 @@
],
"e2e_latency": 0.01,
"cached_tokens": 4,
"reasoning_tokens": 2
"reasoning_tokens": 2,
"weight_version": "new",
"weight_versions": [
{
"version": "old",
"start": 0,
"end": 1
},
{
"version": "new",
"start": 1,
"end": 1
}
],
"image_tokens": 8
},
"output_ids": [
5
],
"index": 0
},
"canonical": "{\"text\":\"ok\",\"meta_info\":{\"id\":\"r\",\"prompt_tokens\":1,\"completion_tokens\":1,\"finish_reason\":{\"type\":\"stop\",\"matched\":[1,2]},\"output_token_logprobs\":[[-0.5,5,\"x\"],[null,6,null]],\"input_token_logprobs\":[],\"output_top_logprobs\":[null,[[-0.25,6,null]]],\"input_top_logprobs\":[[[-0.125,1,\"<s>\"]]],\"output_token_ids_logprobs\":[[[-0.5,9,null]]],\"input_token_ids_logprobs\":[null],\"hidden_states\":[[0.5,0.25],[]],\"e2e_latency\":0.01,\"cached_tokens\":4,\"reasoning_tokens\":2},\"output_ids\":[5],\"index\":0}"
"canonical": "{\"text\":\"ok\",\"meta_info\":{\"id\":\"r\",\"prompt_tokens\":1,\"completion_tokens\":1,\"finish_reason\":{\"type\":\"stop\",\"matched\":[1,2]},\"output_token_logprobs\":[[-0.5,5,\"x\"],[null,6,null]],\"input_token_logprobs\":[],\"output_top_logprobs\":[null,[[-0.25,6,null]]],\"input_top_logprobs\":[[[-0.125,1,\"<s>\"]]],\"output_token_ids_logprobs\":[[[-0.5,9,null]]],\"input_token_ids_logprobs\":[null],\"hidden_states\":[[0.5,0.25],[]],\"e2e_latency\":0.01,\"cached_tokens\":4,\"reasoning_tokens\":2,\"weight_version\":\"new\",\"weight_versions\":[{\"version\":\"old\",\"start\":0,\"end\":1},{\"version\":\"new\",\"start\":1,\"end\":1}],\"image_tokens\":8},\"output_ids\":[5],\"index\":0}"
},
{
"type": "GenerateResponse",
@@ -12,9 +12,13 @@ mod error;
mod frame;
mod handle;
mod hicache;
mod metrics;
mod prefetch;
pub(crate) use error::{CoreError, CoreErrorKind};
#[cfg(test)]
pub(crate) use frame::frame_metadata_case;
pub(crate) use frame::{CoreEvent, CoreOutput, HealthStatus};
pub(crate) use handle::{CoreCall, CoreConfig, CoreHandle, CoreMetadata, recv_indexed};
pub(crate) use hicache::HiCacheManager;
pub(crate) use metrics::{FLUSH_INTERVAL, FrontendMetrics};
+159 -1
View File
@@ -5,11 +5,13 @@
//! as protobuf, so a column added here reaches both the same way. No Axum,
//! SSE, Tonic, or scheduler-channel details.
use std::sync::Arc;
use sglang_api_types::api::v1 as api;
use super::CoreError;
use crate::message::finish_reason::{FinishKind, FinishReason, Matched};
use crate::message::response::{ChunkEvent, ChunkExtras};
use crate::message::response::{ChunkEvent, ChunkExtras, WeightVersionSpan};
use crate::message::types::TokenIds;
/// Canonical transport-neutral output payload for generation events.
@@ -26,6 +28,9 @@ pub(crate) struct CoreOutput {
pub(crate) prompt_tokens: u32,
pub(crate) cached_tokens: u32,
pub(crate) reasoning_tokens: u64,
/// The launch weight version, which frames report until a terminal one's
/// spans name the last; stamped by the core call.
pub(crate) weight_version: Option<Arc<str>>,
pub(crate) text: String,
pub(crate) completion_tokens: u64,
pub(crate) stop_token_trimmed: bool,
@@ -52,6 +57,7 @@ impl From<ChunkEvent> for CoreOutput {
prompt_tokens,
cached_tokens,
reasoning_tokens,
weight_version: None,
text,
completion_tokens,
stop_token_trimmed,
@@ -87,6 +93,9 @@ impl CoreOutput {
self.finish_reason = delta.finish_reason.clone();
self.stop_token_trimmed = delta.stop_token_trimmed;
}
if delta.weight_version.is_some() {
self.weight_version = delta.weight_version.clone();
}
let Some(delta_extras) = delta.extras.as_deref() else {
return;
@@ -153,6 +162,20 @@ impl CoreOutput {
extras.hidden_val = delta_extras.hidden_val.clone();
extras.hidden_lens = delta_extras.hidden_lens.clone();
}
if !delta_extras.weight_versions.is_empty() {
extras.weight_versions = delta_extras.weight_versions.clone();
}
// A request's media counts never change; a frame without them (a
// scheduler abort) keeps the ones the stream reported.
for (count, delta_count) in [
(&mut extras.image_tokens, delta_extras.image_tokens),
(&mut extras.audio_tokens, delta_extras.audio_tokens),
(&mut extras.video_tokens, delta_extras.video_tokens),
] {
if delta_count != 0 {
*count = delta_count;
}
}
}
}
@@ -185,6 +208,16 @@ impl CoreEvent {
}
impl CoreOutput {
/// The scheduler's decode latency for a finished non-streaming request,
/// whose first token this frontend does not see. It rides only that
/// request's terminal delta and is read off it there (for a model package's
/// [`GenerateHook`](crate::GenerateHook)), so [`append_delta`](Self::append_delta)
/// does not fold it into the cumulative output.
pub(crate) fn scheduler_decode_latency(&self) -> Option<std::time::Duration> {
let seconds = self.extras.as_deref()?.decode_latency?;
std::time::Duration::try_from_secs_f64(seconds).ok()
}
/// One `/generate` frame. `index` is the batch position (batch form only);
/// `e2e_latency` rides the terminal frame only.
///
@@ -220,12 +253,23 @@ impl CoreOutput {
e2e_latency,
cached_tokens: self.cached_tokens,
reasoning_tokens: self.reasoning_tokens,
weight_version: self.weight_version.as_deref().map(str::to_owned),
..Default::default()
};
let Some(extras) = self.extras.as_deref() else {
return meta;
};
meta.weight_versions =
visible_weight_versions(&extras.weight_versions, self.completion_tokens);
if let Some(span) = meta.weight_versions.last() {
meta.weight_version = Some(span.version.clone());
}
let nonzero = |count: u32| (count != 0).then_some(count);
meta.image_tokens = nonzero(extras.image_tokens);
meta.audio_tokens = nonzero(extras.audio_tokens);
meta.video_tokens = nonzero(extras.video_tokens);
// Python always sets input and output token logprobs together, including
// the empty input list emitted by a disaggregated decode node.
if !extras.out_lp_val.is_empty() || !extras.in_lp_val.is_empty() {
@@ -279,6 +323,24 @@ impl CoreOutput {
}
}
/// Python's `add_weight_versions_to_meta_info`: the spans that start inside
/// the completion (and the first one, even for an empty completion), each
/// clipped to it.
fn visible_weight_versions(
spans: &[WeightVersionSpan],
completion_tokens: u64,
) -> Vec<api::WeightVersionSpan> {
spans
.iter()
.filter(|span| span.start == 0 || span.start < completion_tokens)
.map(|span| api::WeightVersionSpan {
version: span.version.clone(),
start: span.start as i64,
end: span.end.min(completion_tokens) as i64,
})
.collect()
}
/// A decoded-text column becomes the entries' text source only when populated.
fn opt_texts(texts: &[String]) -> Option<&[String]> {
(!texts.is_empty()).then_some(texts)
@@ -394,6 +456,29 @@ fn matched_value(matched: &Matched) -> api::Matched {
api::Matched { value: Some(value) }
}
/// One case of `testdata/frame_metadata.json` (built by Python's
/// `build_rust_frame_metadata_fixture`), and the events its frame decodes to.
#[cfg(test)]
pub(crate) fn frame_metadata_case(name: &str) -> (serde_json::Value, Vec<ChunkEvent>) {
use crate::message::response::{for_each_chunk, frame_decode_batch_cols};
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../../../testdata/frame_metadata.json")).unwrap();
let case = fixture[name].clone();
let bytes = |hex: &serde_json::Value| -> Vec<u8> {
let hex = hex.as_str().unwrap_or_default();
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect()
};
let data = bytes(&case["data"]);
let framed = frame_decode_batch_cols(&bytes(&case["header"]), &[data.as_slice()]);
let mut events = Vec::new();
assert!(for_each_chunk(&framed[1..], |event| events.push(event)).ok);
(case, events)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -461,6 +546,79 @@ mod tests {
assert_eq!(opt_texts(&texts), Some(texts.as_slice()));
}
/// The weight-version and media-count columns, as Python's
/// `push_generation` and `push_abort` send them, render the `meta_info`
/// keys Python's TokenizerManager derives from the same values
/// (`testdata/frame_metadata.json`, built by
/// `build_rust_frame_metadata_fixture`): spans on a terminal frame, clipped
/// to the completion the client received, the launch version before one,
/// and only nonzero media counts, also behind a frame's logprob columns;
/// the scheduler's decode latency reaches its row, and only the abort is
/// marked as ended outside the output stream.
#[test]
fn frame_metadata_matches_python() {
let keys = |output: &CoreOutput| {
let meta = json(&output.meta_info("rid", None));
let mut keys = serde_json::Map::new();
for key in [
"weight_version",
"weight_versions",
"image_tokens",
"audio_tokens",
"video_tokens",
] {
if let Some(value) = meta.get(key) {
keys.insert(key.into(), value.clone());
}
}
serde_json::Value::Object(keys)
};
let launch = Some(Arc::<str>::from("launch"));
for name in ["batch", "logprob_batch"] {
let (batch, events) = super::frame_metadata_case(name);
assert_eq!(events.len(), 2);
for event in events {
let rid = event.rid.as_str().to_owned();
assert!(!event.extras.as_deref().is_some_and(|e| e.abort_output));
let completion_tokens = event.token_ids.len() as u64;
let mut output = CoreOutput::from(event);
output.weight_version = launch.clone();
output.completion_tokens = completion_tokens;
assert_eq!(keys(&output), batch["meta_info"][&rid], "{name} {rid}");
assert_eq!(
output.scheduler_decode_latency(),
batch["decode_latencies"][&rid]
.as_f64()
.map(std::time::Duration::from_secs_f64),
"{name} {rid}"
);
if rid == "logprobs" {
let meta = json(&output.meta_info(&rid, None));
assert_eq!(
meta["output_token_logprobs"],
serde_json::json!([[-0.5, 7, null], [-0.25, 8, null]])
);
}
}
}
// A scheduler abort folds into what the stream already sent, and its
// spans are clipped to those tokens.
let (abort, mut events) = super::frame_metadata_case("abort");
let mut aborted = CoreOutput {
weight_version: launch.clone(),
completion_tokens: abort["completion_tokens"].as_u64().unwrap(),
..Default::default()
};
let event = events.pop().unwrap();
assert!(event.extras.as_deref().is_some_and(|e| e.abort_output));
let mut delta = CoreOutput::from(event);
delta.weight_version = launch;
aborted.append_delta(&delta);
assert_eq!(keys(&aborted), abort["meta_info"]);
}
/// A trimmed stop token stays in the accumulated `token_ids` but out of the
/// parser view, which must line up with the accumulated text. A stop token
/// kept in the text (`no_stop_trim`) stays in both.
+134 -46
View File
@@ -8,7 +8,7 @@ use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use std::time::{Duration, Instant};
use sglang_api_types::api::v1 as api;
use tokio::sync::{mpsc, oneshot};
@@ -28,6 +28,7 @@ use crate::tokenizer_manager::wiring::{AbortSource, RequestAdmission, TmEvent};
use crate::utils::error::Error;
use crate::utils::fsm::RequestState;
use super::metrics::{FrontendMetrics, RequestMetrics};
use super::{CoreError, CoreEvent, HealthStatus, prefetch};
use crate::message::info::{InternalState, ModelInfo, ServerInfo};
@@ -42,6 +43,8 @@ pub(crate) struct CoreConfig {
pub(crate) is_disaggregation: bool,
pub(crate) mm_limits: Vec<(String, usize)>,
pub(crate) metadata: CoreMetadata,
/// Request metrics, when `--enable-metrics` is on.
pub(crate) metrics: Option<Arc<FrontendMetrics>>,
}
/// Immutable, transport-independent metadata retained by the core.
@@ -53,7 +56,7 @@ pub(crate) struct CoreMetadata {
served_model_name: String,
tokenizer_path: String,
preferred_sampling_params: Option<crate::message::config::PreferredSamplingParams>,
weight_version: Option<String>,
weight_version: Option<Arc<str>>,
load_format: Option<String>,
reasoning_parser: Option<String>,
tool_call_parser: Option<String>,
@@ -75,7 +78,7 @@ impl From<&ServerArgs> for CoreMetadata {
served_model_name: args.served_model_name.clone(),
tokenizer_path: args.tokenizer_path.clone(),
preferred_sampling_params: args.preferred_sampling_params.clone(),
weight_version: args.weight_version.clone(),
weight_version: args.weight_version.as_deref().map(Arc::from),
load_format: args.load_format.clone(),
reasoning_parser: args.reasoning_parser.clone(),
tool_call_parser: args.tool_call_parser.clone(),
@@ -114,6 +117,7 @@ struct CoreInner {
mm_processor: OnceLock<Arc<dyn MmProcessor>>,
metadata: CoreMetadata,
generations: Generations,
metrics: Option<Arc<FrontendMetrics>>,
}
impl CoreHandle {
@@ -134,6 +138,7 @@ impl CoreHandle {
mm_processor: OnceLock::new(),
metadata: config.metadata,
generations: Generations::default(),
metrics: config.metrics,
}),
}
}
@@ -158,10 +163,16 @@ impl CoreHandle {
self.inner.response_activity.load(Ordering::Relaxed)
}
/// The request metrics, when enabled.
pub(crate) fn metrics(&self) -> Option<&Arc<FrontendMetrics>> {
self.inner.metrics.as_ref()
}
pub(crate) async fn generate(
&self,
mut request: GenerateRequest,
) -> Result<CoreCall, CoreError> {
let created_at = Instant::now();
let mut generation = self
.inner
.generations
@@ -171,19 +182,30 @@ impl CoreHandle {
_ = generation.cancelled() => return Err(CoreError::Cancelled("request aborted".into())),
result = self.prepare_generations(std::slice::from_mut(&mut request)) => result?,
}
let metrics = self.request_metrics(&request, created_at);
self.submit_generation(request, generation)
.await
.map(CoreCall::new)
.map(|runtime| self.generation_call(runtime, metrics))
}
/// Submit core-owned work without exposing it through the public Abort
/// registry. Drop still owns cancellation through `RuntimeCall`.
async fn generate_internal(&self, mut request: GenerateRequest) -> Result<CoreCall, CoreError> {
/// `log_metrics` is Python's flag of the same name: the health probe's
/// request is not counted.
async fn generate_internal(
&self,
mut request: GenerateRequest,
log_metrics: bool,
) -> Result<CoreCall, CoreError> {
let created_at = Instant::now();
self.prepare_generations(std::slice::from_mut(&mut request))
.await?;
let metrics = log_metrics
.then(|| self.request_metrics(&request, created_at))
.flatten();
self.submit(RequestKind::Generate(Box::new(request)))
.await
.map(CoreCall::new)
.map(|runtime| self.generation_call(runtime, metrics))
}
/// Prepare the group before submitting any surviving request. Explicit
@@ -194,6 +216,7 @@ impl CoreHandle {
&self,
mut requests: Vec<GenerateRequest>,
) -> Result<Vec<CoreCall>, CoreError> {
let created_at = Instant::now();
if requests.is_empty() {
return Ok(Vec::new());
}
@@ -222,8 +245,9 @@ impl CoreHandle {
let mut calls = Vec::with_capacity(requests.len());
for (request, generation) in requests.into_iter().zip(generations) {
let rid = request.rid.clone();
let metrics = self.request_metrics(&request, created_at);
let call = match self.submit_generation(request, generation).await {
Ok(call) => CoreCall::new(call),
Ok(call) => self.generation_call(call, metrics),
// Cancellation may happen during preparation or while waiting
// for intake. Keep this slot without rolling back siblings.
Err(CoreError::Cancelled(_)) => {
@@ -252,6 +276,19 @@ impl CoreHandle {
.await
}
fn generation_call(&self, runtime: RuntimeCall, metrics: Option<RequestMetrics>) -> CoreCall {
CoreCall::new(runtime, self.inner.metadata.weight_version.clone(), metrics)
}
fn request_metrics(
&self,
request: &GenerateRequest,
created_at: Instant,
) -> Option<RequestMetrics> {
let metrics = self.inner.metrics.as_ref()?;
Some(metrics.start_request(request, created_at))
}
async fn prepare_generations(&self, requests: &mut [GenerateRequest]) -> Result<(), CoreError> {
prefetch::prefetch_all(
requests,
@@ -274,7 +311,7 @@ impl CoreHandle {
tokenizer_path: metadata.tokenizer_path.clone(),
is_generation: true,
preferred_sampling_params: metadata.preferred_sampling_params.clone(),
weight_version: metadata.weight_version.clone(),
weight_version: metadata.weight_version.as_deref().map(str::to_owned),
load_format: metadata.load_format.clone(),
reasoning_parser: metadata.reasoning_parser.clone(),
tool_call_parser: metadata.tool_call_parser.clone(),
@@ -468,7 +505,7 @@ impl CoreHandle {
// The probe is intentionally not drained. Its call stays alive while
// the heartbeat is observed, then aborts on drop to clean up a probe
// that a busy scheduler skipped without producing a terminal frame.
let _probe = self.generate_internal(probe).await?;
let _probe = self.generate_internal(probe, false).await?;
let deadline = tokio::time::Instant::now() + timeout;
loop {
@@ -491,23 +528,26 @@ impl CoreHandle {
(Some("The capital city of France is".to_string()), None)
};
let mut call = self
.generate_internal(GenerateRequest {
rid: Rid::new(),
text,
input_ids,
sampling_params: SamplingParams {
max_new_tokens: Some(8),
temperature: 0.0,
.generate_internal(
GenerateRequest {
rid: Rid::new(),
text,
input_ids,
sampling_params: SamplingParams {
max_new_tokens: Some(8),
temperature: 0.0,
..Default::default()
},
stream: false,
bootstrap_host: self
.inner
.is_disaggregation
.then(|| FAKE_BOOTSTRAP_HOST.into()),
bootstrap_room: self.inner.is_disaggregation.then_some(0),
..Default::default()
},
stream: false,
bootstrap_host: self
.inner
.is_disaggregation
.then(|| FAKE_BOOTSTRAP_HOST.into()),
bootstrap_room: self.inner.is_disaggregation.then_some(0),
..Default::default()
})
true,
)
.await?;
loop {
match call.recv().await {
@@ -600,13 +640,30 @@ fn translate_runtime_error(error: Error) -> CoreError {
pub(crate) struct CoreCall {
runtime: RuntimeCall,
events_finished: bool,
/// The launch weight version, stamped on every output (see
/// [`CoreOutput::weight_version`](super::CoreOutput)).
weight_version: Option<Arc<str>>,
/// Observes every runtime item as the call receives it.
metrics: Option<RequestMetrics>,
}
impl CoreCall {
fn new(runtime: RuntimeCall) -> Self {
fn new(
runtime: RuntimeCall,
weight_version: Option<Arc<str>>,
metrics: Option<RequestMetrics>,
) -> Self {
Self {
runtime,
events_finished: false,
weight_version,
metrics,
}
}
fn observe(&mut self, item: Option<&ResponseItem>) {
if let Some(metrics) = &mut self.metrics {
metrics.observe(item);
}
}
@@ -616,15 +673,19 @@ impl CoreCall {
response_tx
.try_send(ResponseItem::Error(Error::Disconnected))
.expect("new response channel has room for its terminal error");
Self::new(RuntimeCall {
rid,
response_rx,
abort_tx,
admission: RequestAdmission::pending(),
expected_response: ExpectedResponse::Generate,
in_flight: false,
generation: None,
})
Self::new(
RuntimeCall {
rid,
response_rx,
abort_tx,
admission: RequestAdmission::pending(),
expected_response: ExpectedResponse::Generate,
in_flight: false,
generation: None,
},
None,
None,
)
}
/// ID that adapters should return to their client.
@@ -640,8 +701,10 @@ impl CoreCall {
return None;
}
let event = match self.runtime.recv().await {
Some(item) => generation_event(item),
let item = self.runtime.recv().await;
self.observe(item.as_ref());
let event = match item {
Some(item) => self.generation_event(item),
None => CoreEvent::Failed(CoreError::Internal(
"response truncated before completion".into(),
)),
@@ -655,7 +718,10 @@ impl CoreCall {
pub(crate) fn drain_ready(&mut self, events: &mut Vec<CoreEvent>) {
while !self.events_finished {
let event = match self.runtime.try_recv() {
Ok(item) => generation_event(item),
Ok(item) => {
self.observe(Some(&item));
self.generation_event(item)
}
// Preserve every queued event before reporting a premature
// close. The next awaited `recv` translates that close into
// one terminal failure, maintaining stream order while still
@@ -677,15 +743,35 @@ impl CoreCall {
) -> Self {
let admission = RequestAdmission::pending();
assert!(admission.try_accept(), "test calls start already admitted");
Self::new(RuntimeCall {
rid,
response_rx,
abort_tx,
admission,
expected_response: ExpectedResponse::Generate,
in_flight: true,
generation: None,
})
Self::new(
RuntimeCall {
rid,
response_rx,
abort_tx,
admission,
expected_response: ExpectedResponse::Generate,
in_flight: true,
generation: None,
},
None,
None,
)
}
fn generation_event(&self, item: ResponseItem) -> CoreEvent {
let mut event = generation_event(item);
if let CoreEvent::Delta(output) | CoreEvent::Finished(output) = &mut event {
output.weight_version = self.weight_version.clone();
}
event
}
}
impl Drop for CoreCall {
fn drop(&mut self) {
if let Some(metrics) = &self.metrics {
metrics.call_dropped();
}
}
}
@@ -1062,6 +1148,7 @@ mod tests {
is_disaggregation,
mm_limits,
metadata: CoreMetadata::default(),
metrics: None,
},
)
}
@@ -1094,6 +1181,7 @@ mod tests {
startup_ready: false,
is_disaggregation: false,
mm_limits: Vec::new(),
metrics: None,
metadata: CoreMetadata {
served_model_name: "served-model".into(),
max_context_length: 1_u64 << 32,
@@ -162,6 +162,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: CoreMetadata::default(),
metrics: None,
},
);
let manager = Arc::new(HiCacheManager::new(core, &ServerArgs::default()));
File diff suppressed because it is too large. Load diff
@@ -479,6 +479,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(server_args),
generate_hook: None,
};
(
crate::runtime::start(cfg, None).expect("start runtime"),
@@ -326,6 +326,7 @@ mod tests {
},
..Default::default()
}),
metrics: None,
},
);
Self {
@@ -659,6 +660,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: CoreMetadata::default(),
metrics: None,
},
);
let service = GrpcService::for_test(core, None, false, Duration::from_secs(1));
@@ -1,4 +1,5 @@
pub(crate) mod app;
pub(crate) mod generate_hook;
mod hicache;
mod info;
mod loads;
+18 -1
View File
@@ -7,9 +7,10 @@ use std::time::Duration;
use axum::Router;
use super::generate_hook::GenerateHook;
use super::{hicache, info, loads, metrics, native_api, openai};
use crate::HttpLayer;
use crate::api_server::core::{CoreHandle, HiCacheManager};
use crate::api_server::core::{CoreHandle, FLUSH_INTERVAL, HiCacheManager};
use crate::api_server::disaggregation::bootstrap as pd_bootstrap;
use crate::api_server::log;
use crate::message::config::ServerArgs;
@@ -28,6 +29,7 @@ pub(super) struct AppState {
pub(super) server_args: Arc<ServerArgs>,
pub(super) generation_policy: GenerationPolicy,
pub(super) chat_formatter: Option<openai::ChatFormatter>,
pub(super) generate_hook: Option<Arc<dyn GenerateHook>>,
}
/// Every listener warms its own rank; health stays 503 until it succeeds,
@@ -64,6 +66,7 @@ pub async fn serve(
// Whether this listener serves the node's `/metrics`.
metrics_listener: bool,
http_layer: Option<HttpLayer>,
generate_hook: Option<Arc<dyn GenerateHook>>,
// The runtime's shutdown signal, shared with every worker stage: it fires
// (disconnects) when `Runtime::request_shutdown` drops the sender, at
// which point `serve` stops accepting and its in-flight handlers are
@@ -78,6 +81,7 @@ pub async fn serve(
server_args: server_args.clone(),
generation_policy,
chat_formatter,
generate_hook,
});
// Cancelled with the api runtime on shutdown.
tokio::spawn(startup_warmup(state.core.clone(), server_args.clone()));
@@ -91,6 +95,19 @@ pub async fn serve(
if server_args.metrics.is_some() && metrics_listener {
router = router.merge(metrics::routes());
}
// Keep this frontend's metrics files current for the directory's other
// readers (the node's metrics listener, when this is a DP rank's offset
// listener); stops with the runtime.
if let Some(frontend) = state.core.metrics().cloned() {
tokio::spawn(async move {
let mut interval = tokio::time::interval(FLUSH_INTERVAL);
loop {
interval.tick().await;
let frontend = frontend.clone();
let _ = tokio::task::spawn_blocking(move || frontend.flush_or_warn()).await;
}
});
}
// TODO(auth): no API-key boundary yet. Python gates every route (except
// /health*, /metrics*, OPTIONS) via `add_api_key_middleware`; until ported,
@@ -0,0 +1,46 @@
//! The seam a model package uses to add its own keys to native `/generate`
//! frames — deployment metadata the shared schema does not model. Without a
//! hook the frames are exactly the `api.v1` schema.
use std::time::Duration;
use serde_json::{Map, Value};
use crate::message::sampling::SamplingParams;
/// Adds keys to native `/generate` frames; installed at launch with
/// [`crate::Server::start_with_extensions`].
pub trait GenerateHook: Send + Sync + std::fmt::Debug {
/// The annotator for one submitted request, from its sampling parameters
/// as submitted (the launch-time preferred params merged in, not yet
/// normalized).
fn start(&self, sampling_params: &SamplingParams) -> Box<dyn GenerateFrameHook>;
}
/// One request's annotator, called for each frame the request emits, in
/// order, the terminal one last.
pub trait GenerateFrameHook: Send {
fn annotate(&mut self, frame: &GenerateFrameInfo, keys: &mut GenerateFrameKeys);
}
/// What an annotator sees of one frame.
#[derive(Clone, Copy, Debug, Default)]
pub struct GenerateFrameInfo {
/// The request's terminal frame.
pub finished: bool,
/// On the terminal frame, the time from the request's first token to its
/// last output, as Python's TokenizerManager measures it
/// (`get_decode_latency`): for a non-streaming request, from the
/// scheduler's prefill stamp when it has one.
pub decode_latency: Option<Duration>,
}
/// Keys an annotator adds to one frame. They must not repeat a key the schema
/// already writes there.
#[derive(Debug, Default)]
pub struct GenerateFrameKeys {
/// Added to the frame's `meta_info`.
pub meta_info: Map<String, Value>,
/// Added to the frame object, after the schema's keys.
pub frame: Map<String, Value>,
}
@@ -99,6 +99,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: (&args).into(),
metrics: None,
},
);
(
@@ -344,6 +344,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: CoreMetadata::from(server_args.as_ref()),
metrics: None,
},
);
(
@@ -354,6 +355,7 @@ mod tests {
),
server_args,
chat_formatter: None,
generate_hook: None,
}),
intake_rx,
abort_rx,
@@ -12,6 +12,7 @@ use std::fs::File;
use std::os::unix::fs::FileExt;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
@@ -170,6 +171,7 @@ async fn loads(State(state): State<Arc<AppState>>, Query(query): Query<LoadsQuer
None | Some(0) => vec![args.load_snapshot_dp_rank],
Some(_) => Vec::new(),
};
let started = Instant::now();
let loads: Vec<Map<String, Value>> =
read_snapshots(Path::new(path), args.load_snapshot_dp_size, &ranks)
.iter()
@@ -179,6 +181,9 @@ async fn loads(State(state): State<Arc<AppState>>, Query(query): Query<LoadsQuer
load
})
.collect();
if let Some(metrics) = state.core.metrics() {
metrics.observe_get_loads(started.elapsed());
}
if query.format.as_deref() == Some("prometheus") {
return (
@@ -474,11 +479,13 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
),
generation_policy: crate::message::request::GenerationPolicy::from(&server_args),
server_args: Arc::new(server_args),
chat_formatter: None,
generate_hook: None,
});
let query = Query::try_from_uri(&format!("/v1/loads?{query}").parse().unwrap()).unwrap();
let response = loads(State(state), query).await;
@@ -1,7 +1,9 @@
//! `GET /metrics`: the port of Python's `add_prometheus_middleware`. With
//! `--enable-metrics` every process writes its prometheus_client samples
//! under `PROMETHEUS_MULTIPROC_DIR`; each scrape merges all of them and
//! renders the text format (see [`crate::utils::prometheus`]), off the GIL.
//! under `PROMETHEUS_MULTIPROC_DIR`, this frontend included (see
//! [`crate::api_server::core::FrontendMetrics`]); each scrape merges all of
//! them and renders the text format (see [`crate::utils::prometheus`]), off
//! the GIL.
use std::path::PathBuf;
use std::sync::Arc;
@@ -33,8 +35,13 @@ async fn metrics(State(state): State<Arc<AppState>>) -> Response {
return StatusCode::NOT_FOUND.into_response();
};
let dir = PathBuf::from(&config.multiproc_dir);
// Reading and merging every process's files is blocking file I/O.
let frontend = state.core.metrics().cloned();
// Writing this frontend's file and reading every process's is blocking
// file I/O. The write is best effort: what is on disk is still served.
let collected = tokio::task::spawn_blocking(move || {
if let Some(frontend) = frontend {
frontend.flush_or_warn();
}
let files = prometheus::read_dir_files(&dir)?;
let families = prometheus::merge(
files
@@ -58,14 +65,15 @@ async fn metrics(State(state): State<Arc<AppState>>) -> Response {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::config::{MetricsConfig, ServerArgs};
use crate::api_server::core::FrontendMetrics;
use crate::message::config::{DisaggregationMode, MetricsConfig, ServerArgs};
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn app(metrics: Option<MetricsConfig>) -> Router {
fn app(config: MetricsConfig, frontend: Option<Arc<FrontendMetrics>>) -> Router {
let server_args = Arc::new(ServerArgs {
metrics,
metrics: Some(config),
..Default::default()
});
let state = Arc::new(AppState {
@@ -79,6 +87,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: Default::default(),
metrics: frontend,
},
),
generation_policy: crate::message::request::GenerationPolicy::from(
@@ -86,6 +95,7 @@ mod tests {
),
server_args,
chat_formatter: None,
generate_hook: None,
});
routes().with_state(state)
}
@@ -111,40 +121,59 @@ mod tests {
)
}
/// A scrape merges every process's file in the directory; a missing
/// directory is a 500, as Python's collector raises.
/// A scrape merges every process's file in the directory, writing the
/// frontend's own first if it can; a missing directory is a 500, as
/// Python's collector raises.
#[tokio::test]
async fn serves_the_merged_directory() {
let dir = std::env::temp_dir().join(format!("metrics-route-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Two processes' counter files for one series.
let file = |value: f64| {
let key = br#"["sglang:x", "sglang:x_total", {}, "X."]"#;
let mut data = vec![0u8; 8];
data.extend_from_slice(&(key.len() as u32).to_le_bytes());
data.extend_from_slice(key);
data.extend_from_slice(b" ");
data.extend_from_slice(&value.to_le_bytes());
data.extend_from_slice(&0f64.to_le_bytes());
let used = data.len() as u32;
data[..4].copy_from_slice(&used.to_le_bytes());
data
};
std::fs::write(dir.join("counter_1.db"), file(2.0)).unwrap();
std::fs::write(dir.join("counter_2.db"), file(3.0)).unwrap();
let config = MetricsConfig {
multiproc_dir: dir.to_string_lossy().into_owned(),
};
for (pid, value) in [(1, 2.0), (2, 3.0)] {
let entry = prometheus::Entry {
key: prometheus::mmap_key("sglang:x", "sglang:x_total", &[], "X."),
value,
timestamp: 0.0,
};
let data = prometheus::encode_entries([&entry]);
prometheus::write_file(&dir, &format!("counter_{pid}.db"), &data).unwrap();
}
let config = MetricsConfig::for_dir(&dir);
let expected =
"# HELP sglang:x_total X.\n# TYPE sglang:x_total counter\nsglang:x_total 5.0\n";
for uri in ["/metrics", "/metrics/", "/metrics/any"] {
let (status, content_type, body) = get(app(Some(config.clone())), uri).await;
let (status, content_type, body) = get(app(config.clone(), None), uri).await;
assert_eq!(status, StatusCode::OK, "{uri}");
assert_eq!(content_type, CONTENT_TYPE);
assert_eq!(body, expected);
}
let frontend = Arc::new(FrontendMetrics::new(&config, DisaggregationMode::Null).unwrap());
frontend.observe_get_loads(std::time::Duration::from_micros(300));
let (_, _, body) = get(app(config.clone(), Some(frontend)), "/metrics").await;
assert!(body.contains(expected), "{body}");
assert!(
body.contains("sglang:get_loads_duration_seconds_bucket{le=\"0.0005\"} 1.0\n"),
"{body}"
);
// A frontend file that cannot be written (here a directory in its
// place) still serves what is on disk.
let blocked = Arc::new(FrontendMetrics::new(&config, DisaggregationMode::Null).unwrap());
blocked.observe_get_loads(std::time::Duration::from_micros(300));
std::fs::remove_file(dir.join(format!("histogram_rust-{}.db", std::process::id())))
.unwrap();
std::fs::create_dir_all(
dir.join(format!("histogram_rust-{}.db", std::process::id()))
.join("x"),
)
.unwrap();
let (status, _, body) = get(app(config.clone(), Some(blocked)), "/metrics").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains(expected), "{body}");
std::fs::remove_dir_all(&dir).unwrap();
let (status, _, _) = get(app(Some(config)), "/metrics").await;
let (status, _, _) = get(app(config, None), "/metrics").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
}
@@ -11,6 +11,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use super::app::AppState;
use super::generate_hook::{GenerateFrameHook, GenerateFrameInfo, GenerateFrameKeys};
use super::request_headers::apply_header_overrides;
use crate::api_server::core::{
CoreCall, CoreError, CoreEvent, CoreOutput, HealthStatus, recv_indexed,
@@ -75,6 +76,137 @@ impl RequestTiming {
fn terminal_latencies(&self) -> Option<(Duration, Duration)> {
Some((self.time_to_first_token?, self.e2e_latency?))
}
/// First output to terminal output, once the request finished.
fn decode_latency(&self) -> Option<Duration> {
let (time_to_first_token, e2e_latency) = self.terminal_latencies()?;
Some(e2e_latency.saturating_sub(time_to_first_token))
}
}
/// API-local state for one request: its timing, and the annotator a model
/// package's [`GenerateHook`](super::generate_hook::GenerateHook) started for
/// it.
struct RequestState {
timing: RequestTiming,
/// The scheduler's decode latency, sent for a finished non-streaming
/// request: this frontend sees that request's first output only up to
/// `SGLANG_FORCE_STREAM_INTERVAL` tokens late, not when its first token
/// was sampled.
scheduler_decode_latency: Option<Duration>,
hook: Option<Box<dyn GenerateFrameHook>>,
}
impl RequestState {
fn new(state: &AppState, request: &GenerateRequest, timing: RequestTiming) -> Self {
Self {
timing,
scheduler_decode_latency: None,
hook: state
.generate_hook
.as_ref()
.map(|hook| hook.start(&request.sampling_params)),
}
}
fn finish(&mut self, terminal: &CoreOutput) {
self.timing.observe_first_output();
self.timing.finish();
self.scheduler_decode_latency = terminal.scheduler_decode_latency();
}
/// From the first token to the terminal output, as Python's
/// TokenizerManager measures it: the scheduler's measure when it sent one,
/// else this frontend's first and terminal outputs.
fn decode_latency(&self) -> Option<Duration> {
self.scheduler_decode_latency
.or_else(|| self.timing.decode_latency())
}
/// One frame as the client receives it: the schema's, plus the hook's keys.
fn frame(&mut self, frame: api::GenerateResponse) -> NativeFrame {
let finished = frame
.meta_info
.as_ref()
.is_some_and(|meta| meta.finish_reason.is_some());
let info = GenerateFrameInfo {
finished,
decode_latency: if finished {
self.decode_latency()
} else {
None
},
};
let Some(hook) = self.hook.as_mut() else {
return NativeFrame { frame, keys: None };
};
let mut keys = GenerateFrameKeys::default();
hook.annotate(&info, &mut keys);
NativeFrame {
frame,
keys: Some(Box::new(keys)),
}
}
}
/// One native `/generate` frame: the `api.v1` frame, plus the keys a model
/// package's hook added after the schema's own.
#[derive(Debug)]
struct NativeFrame {
frame: api::GenerateResponse,
keys: Option<Box<GenerateFrameKeys>>,
}
impl serde::Serialize for NativeFrame {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap; // codespell:ignore ser
/// A schema object's keys, then the added ones.
#[derive(serde::Serialize)]
struct WithKeys<'a, T> {
#[serde(flatten)]
schema: &'a T,
#[serde(flatten)]
added: &'a serde_json::Map<String, serde_json::Value>,
}
let Some(keys) = self.keys.as_deref() else {
return self.frame.serialize(serializer);
};
// `GenerateResponse`'s generated serializer, key for key, with the
// added keys in `meta_info` and after the frame's own. Destructured
// whole so a field the schema gains fails to compile here until it is
// written.
let api::GenerateResponse {
text,
meta_info,
output_ids,
index,
prompt_token_ids,
} = &self.frame;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("text", text)?;
if let Some(meta_info) = meta_info {
let meta_info = WithKeys {
schema: meta_info,
added: &keys.meta_info,
};
map.serialize_entry("meta_info", &meta_info)?;
}
if let Some(output_ids) = output_ids {
map.serialize_entry("output_ids", output_ids)?;
}
if let Some(index) = index {
map.serialize_entry("index", index)?;
}
if let Some(prompt_token_ids) = prompt_token_ids {
map.serialize_entry("prompt_token_ids", prompt_token_ids)?;
}
for (key, value) in &keys.frame {
map.serialize_entry(key, value)?;
}
map.end()
}
}
/// The routes this module owns, mounted by `api_server::serve`.
@@ -258,6 +390,7 @@ async fn generate_single(
stream: bool,
timing: RequestTiming,
) -> Response {
let request = RequestState::new(state, &req, timing);
// `return_text_in_logprobs` is decoded on the detok shard into `*_txt`, so
// the frame builder just reads them — no tokenizer needed here.
let mut call = match state.core.generate(req).await {
@@ -272,13 +405,13 @@ async fn generate_single(
// A single request is a 1-element batch without the `index` field — reuse
// the same stream so the frame/abort/truncation logic lives in one place.
use futures::StreamExt;
let s = generation_event_stream(vec![(call, timing)], incremental, false)
let s = generation_event_stream(vec![(call, request)], incremental, false)
.map(|data| Ok::<_, Infallible>(Event::default().data(data)));
Sse::new(s).into_response()
} else {
// Unary: fold to the terminal and respond once. `CoreCall` disarms
// itself on a terminal item; truncation leaves it armed for drop cleanup.
let (status, item) = drain_unary(&mut call, &rid, timing).await;
let (status, item) = drain_unary(&mut call, &rid, request).await;
(status, Json(item)).into_response()
}
}
@@ -290,7 +423,7 @@ async fn generate_single(
enum GenerateItem {
// Boxed: a frame is ~300 bytes against the error's ~40, and a batch
// array holds one per item.
Frame(Box<api::GenerateResponse>),
Frame(Box<NativeFrame>),
Error(api::GenerateStreamError),
}
@@ -298,24 +431,24 @@ enum GenerateItem {
async fn drain_unary(
call: &mut CoreCall,
rid_str: &str,
mut timing: RequestTiming,
mut request: RequestState,
) -> (StatusCode, GenerateItem) {
let mut accumulated = CoreOutput::default();
while let Some(item) = call.recv().await {
match item {
CoreEvent::Delta(out) => {
timing.observe_first_output();
request.timing.observe_first_output();
accumulated.append_delta(&out);
}
CoreEvent::Finished(out) => {
timing.observe_first_output();
timing.finish();
request.finish(&out);
accumulated.append_delta(&out);
let frame = accumulated.frame(rid_str, None, Some(e2e_latency(&timing)));
let e2e_latency = Some(e2e_latency(&request.timing));
let frame = request.frame(accumulated.frame(rid_str, None, e2e_latency));
return (StatusCode::OK, GenerateItem::Frame(Box::new(frame)));
}
CoreEvent::Failed(error) => {
timing.finish();
request.timing.finish();
return (
error.http_status(),
GenerateItem::Error(error.stream_error(None)),
@@ -344,14 +477,15 @@ async fn generate_batch(
stream: bool,
timing: RequestTiming,
) -> Response {
let request_states: Vec<RequestState> = requests
.iter()
.map(|request| RequestState::new(state, request, timing.clone()))
.collect();
// No cross-item rid collision to worry about: `into_requests` rejected duplicate
// rids within this batch, and `Rid::from_client` made each one unique against
// every other in-flight request.
let calls = match state.core.generate_batch(requests).await {
Ok(calls) => calls
.into_iter()
.map(|call| (call, timing.clone()))
.collect(),
Ok(calls) => calls.into_iter().zip(request_states).collect(),
Err(error) => return native_core_error(error, stream),
};
@@ -367,14 +501,13 @@ async fn generate_batch(
// Unary: poll every item concurrently, as Python's gather does. `join_all`
// preserves input order for the final JSON array, while each drain observes
// its own terminal output promptly (important for per-item e2e_latency).
let drained = futures::future::join_all(calls.into_iter().map(
|(mut call, request_timing)| async move {
let drained =
futures::future::join_all(calls.into_iter().map(|(mut call, request)| async move {
let client_rid = call.public_id().to_owned();
let (_status, item) = drain_unary(&mut call, &client_rid, request_timing).await;
let (_status, item) = drain_unary(&mut call, &client_rid, request).await;
item
},
))
.await;
}))
.await;
(StatusCode::OK, Json(drained)).into_response()
}
}
@@ -383,7 +516,7 @@ async fn generate_batch(
/// each frame (batch only), `incremental` = delta vs cumulative. Each call
/// aborts itself if the stream is dropped while it is unfinished.
fn generation_event_stream(
calls: Vec<(CoreCall, RequestTiming)>,
calls: Vec<(CoreCall, RequestState)>,
incremental: bool,
with_index: bool,
) -> impl futures::Stream<Item = String> {
@@ -395,10 +528,7 @@ fn generation_event_stream(
.iter()
.map(|(call, _)| call.public_id().to_owned())
.collect();
let mut timings: Vec<RequestTiming> = calls
.iter()
.map(|(_, timing)| timing.clone())
.collect();
let mut requests: Vec<RequestState> = Vec::with_capacity(n);
let mut accumulated: Vec<CoreOutput> = (0..n).map(|_| CoreOutput::default()).collect();
// Batch position, tagged onto every frame (a single request omits it).
@@ -407,7 +537,8 @@ fn generation_event_stream(
// Poll all receivers concurrently; re-arm a receiver's future after each
// non-terminal frame so its stream keeps flowing.
let mut futs = futures::stream::FuturesUnordered::new();
for (i, (call, _)) in calls.into_iter().enumerate() {
for (i, (call, request)) in calls.into_iter().enumerate() {
requests.push(request);
futs.push(recv_indexed(i, call));
}
@@ -429,22 +560,22 @@ fn generation_event_stream(
for item in items {
match item {
CoreEvent::Delta(out) => {
timings[i].observe_first_output();
requests[i].timing.observe_first_output();
accumulated[i].append_delta(&out);
if incremental {
yield json_string(&delta_frame(out, &accumulated[i], &rid_strs[i], idx(i), None));
let frame = delta_frame(out, &accumulated[i], &rid_strs[i], idx(i), None);
yield json_string(&requests[i].frame(frame));
} else {
coalesced = true;
}
}
CoreEvent::Finished(out) => {
timings[i].observe_first_output();
timings[i].finish();
requests[i].finish(&out);
accumulated[i].append_delta(&out);
terminal = Some(out);
}
CoreEvent::Failed(error) => {
timings[i].finish();
requests[i].timing.finish();
failed = Some(error);
}
}
@@ -455,7 +586,7 @@ fn generation_event_stream(
} else if let Some(out) = terminal {
// The final frame carries the full cumulative state, so any
// coalesced non-terminal frames are moot.
let e2e_latency = Some(e2e_latency(&timings[i]));
let e2e_latency = Some(e2e_latency(&requests[i].timing));
// The terminal frame is the accumulated output's last use, so
// the cumulative form moves it out instead of cloning.
let frame = if incremental {
@@ -463,12 +594,13 @@ fn generation_event_stream(
} else {
std::mem::take(&mut accumulated[i]).frame(&rid_strs[i], idx(i), e2e_latency)
};
yield json_string(&frame);
yield json_string(&requests[i].frame(frame));
} else {
if coalesced {
// An intermediate cumulative frame has to clone: the accumulated
// output stays behind for the next one.
yield json_string(&accumulated[i].clone().frame(&rid_strs[i], idx(i), None));
let frame = accumulated[i].clone().frame(&rid_strs[i], idx(i), None);
yield json_string(&requests[i].frame(frame));
}
futs.push(recv_indexed(i, call)); // keep this item flowing
}
@@ -547,7 +679,7 @@ mod tests {
serde_json::from_str(s).expect("frame is JSON")
}
fn timed_call(rid: u64, rx: mpsc::Receiver<ResponseItem>) -> (CoreCall, RequestTiming) {
fn timed_call(rid: u64, rx: mpsc::Receiver<ResponseItem>) -> (CoreCall, RequestState) {
timed_call_with_abort(rid, rx, flume::unbounded().0)
}
@@ -555,13 +687,17 @@ mod tests {
rid: u64,
rx: mpsc::Receiver<ResponseItem>,
abort_tx: flume::Sender<crate::tokenizer_manager::wiring::AbortSource>,
) -> (CoreCall, RequestTiming) {
) -> (CoreCall, RequestState) {
(
CoreCall::from_test_generation_parts(Rid::from(rid.to_string()), rx, abort_tx),
RequestTiming {
created_at: Instant::now() - Duration::from_millis(10),
time_to_first_token: None,
e2e_latency: None,
RequestState {
timing: RequestTiming {
created_at: Instant::now() - Duration::from_millis(10),
time_to_first_token: None,
e2e_latency: None,
},
scheduler_decode_latency: None,
hook: None,
},
)
}
@@ -581,11 +717,13 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
),
server_args: Arc::new(crate::message::config::ServerArgs::default()),
generation_policy: GenerationPolicy::default(),
chat_formatter: None,
generate_hook: None,
});
let response = health_generate(State(state), Duration::ZERO, true).await;
@@ -621,11 +759,13 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
),
generation_policy: crate::message::request::GenerationPolicy::from(&server_args),
server_args: Arc::new(server_args),
chat_formatter: None,
generate_hook: None,
});
// Each probe that reaches intake counts as scheduler output.
tokio::spawn(async move {
@@ -658,6 +798,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
);
let state = Arc::new(AppState {
@@ -665,6 +806,7 @@ mod tests {
server_args: Arc::new(crate::message::config::ServerArgs::default()),
generation_policy: GenerationPolicy::default(),
chat_formatter: None,
generate_hook: None,
});
let responder = tokio::spawn(async move {
@@ -736,6 +878,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
);
let state = Arc::new(AppState {
@@ -743,6 +886,7 @@ mod tests {
generation_policy: GenerationPolicy::from(&server_args),
server_args: Arc::new(server_args),
chat_formatter: None,
generate_hook: None,
});
let responder = tokio::spawn(async move {
let mut submitted = Vec::new();
@@ -885,10 +1029,12 @@ mod tests {
"later output must not overwrite TTFT"
);
assert!(timing.decode_latency().is_none(), "unfinished");
timing.finish();
let (recorded_ttft, e2e_latency) = timing.terminal_latencies().unwrap();
assert_eq!(recorded_ttft, time_to_first_token);
assert!(e2e_latency >= recorded_ttft);
assert_eq!(timing.decode_latency(), Some(e2e_latency - recorded_ttft));
timing.finish();
assert_eq!(
@@ -898,6 +1044,304 @@ mod tests {
);
}
/// A model package's hook: the request's `top_p` on every frame, a
/// top-level key on its first frame, and the decode latency on its last.
#[derive(Debug)]
struct TopPHook;
struct TopPFrames {
top_p: f64,
first: bool,
}
impl crate::GenerateHook for TopPHook {
fn start(&self, sampling_params: &crate::SamplingParams) -> Box<dyn GenerateFrameHook> {
Box::new(TopPFrames {
top_p: sampling_params.top_p,
first: true,
})
}
}
impl GenerateFrameHook for TopPFrames {
fn annotate(&mut self, frame: &GenerateFrameInfo, keys: &mut GenerateFrameKeys) {
assert_eq!(frame.finished, frame.decode_latency.is_some());
keys.meta_info
.insert("top_p".into(), serde_json::json!(self.top_p));
if let Some(latency) = frame.decode_latency {
keys.meta_info
.insert("decode_s".into(), serde_json::json!(latency.as_secs_f64()));
}
if std::mem::take(&mut self.first) {
keys.frame.insert("first".into(), serde_json::json!(true));
}
}
}
/// A hook's keys reach every native frame of every batch item — unary,
/// cumulative and incremental streams — each item's hook started from its
/// own sampling params, with the scheduler's decode latency for a unary
/// item and this frontend's for a streamed one; without a hook the frames
/// are the schema's.
#[tokio::test]
async fn generate_hook_keys_reach_every_frame() {
for (stream, incremental, hook) in [
(false, false, true),
(true, false, true),
(true, true, true),
(true, true, false),
] {
let (intake_tx, intake_rx) = flume::unbounded();
let core = crate::api_server::core::CoreHandle::new(
intake_tx,
flume::unbounded().0,
crate::api_server::core::CoreConfig {
response_capacity: 8,
response_activity: Default::default(),
startup_ready: true,
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::default(),
metrics: None,
},
);
let state = Arc::new(AppState {
core,
server_args: Arc::new(crate::message::config::ServerArgs {
incremental_streaming_output: incremental,
..Default::default()
}),
generation_policy: GenerationPolicy::default(),
chat_formatter: None,
generate_hook: hook.then(|| Arc::new(TopPHook) as Arc<dyn crate::GenerateHook>),
});
// Both items' delta, then both terminals: an incremental stream
// emits two frames per item.
let responder = tokio::spawn(async move {
let mut sinks = Vec::new();
for _ in 0..2 {
let TmEvent::Intake { request, admission } =
intake_rx.recv_async().await.unwrap()
else {
panic!("expected a generation intake");
};
assert!(admission.try_accept());
sinks.push((request.rid.clone(), request.sink.clone()));
}
for (rid, sink) in &sinks {
let ResponseItem::Frame(mut chunk) = frame(0, "a") else {
unreachable!()
};
chunk.rid = rid.clone();
sink.try_send(ResponseItem::Frame(chunk)).unwrap();
}
for (rid, sink) in &sinks {
let ResponseItem::Done(mut chunk) = done(0, "b") else {
unreachable!()
};
chunk.rid = rid.clone();
// The scheduler times non-streaming requests only.
chunk.extras = (!stream).then(|| {
Box::new(crate::message::response::ChunkExtras {
decode_latency: Some(0.25),
..Default::default()
})
});
sink.try_send(ResponseItem::Done(chunk)).unwrap();
}
});
let body = serde_json::json!({
"text": ["x", "y"],
"stream": stream,
"sampling_params": [{"top_p": 0.5}, {"top_p": 0.25}],
});
let request = Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let response = routes().with_state(state).oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.unwrap();
responder.await.unwrap();
let body = std::str::from_utf8(&body).unwrap();
let frames: Vec<serde_json::Value> = if stream {
body.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter(|data| *data != "[DONE]")
.map(parse)
.collect()
} else {
parse(body).as_array().unwrap().clone()
};
let case = format!("stream={stream} incremental={incremental} hook={hook}");
assert_eq!(frames.len(), if incremental { 4 } else { 2 }, "{case}");
let mut seen = [0usize; 2];
for (position, frame) in frames.iter().enumerate() {
// A unary batch is an array in request order, without `index`.
let index = frame["index"].as_u64().map_or(position, |i| i as usize);
let meta = &frame["meta_info"];
let terminal = !meta["finish_reason"].is_null();
if !hook {
assert!(meta.get("top_p").is_none() && frame.get("first").is_none());
continue;
}
assert_eq!(meta["top_p"], [0.5, 0.25][index], "{case}");
assert_eq!(frame.get("first").is_some(), seen[index] == 0, "{case}");
assert_eq!(meta.get("decode_s").is_some(), terminal, "{case}");
if terminal && !stream {
assert_eq!(meta["decode_s"], 0.25, "{case}");
}
seen[index] += 1;
}
}
}
/// The hook-aware serializer writes the generated one's keys, in its
/// order, before the hook's.
#[test]
fn native_frame_keeps_the_schema_keys_first() {
let schema = api::GenerateResponse {
text: "ok".into(),
meta_info: Some(api::GenerateMetaInfo {
id: "r".into(),
prompt_tokens: 2,
..Default::default()
}),
output_ids: Some(api::TokenIds { ids: vec![7] }),
index: Some(1),
prompt_token_ids: None,
};
let expected = json_string(&schema);
let empty = NativeFrame {
frame: schema.clone(),
keys: Some(Box::default()),
};
assert_eq!(json_string(&empty), expected);
let mut keys = GenerateFrameKeys::default();
keys.meta_info.insert("m".into(), serde_json::json!(1));
keys.frame.insert("f".into(), serde_json::json!({"k": "v"}));
let added = NativeFrame {
frame: schema,
keys: Some(Box::new(keys)),
};
let mut with_meta: serde_json::Value = parse(&expected);
with_meta["meta_info"]["m"] = serde_json::json!(1);
with_meta["f"] = serde_json::json!({"k": "v"});
assert_eq!(json_string(&added), with_meta.to_string());
}
/// A recording hook's annotator: every frame it was handed, in order, each
/// frame marked with its position as `meta_info.hook_frame`.
#[derive(Clone, Default)]
struct Recorded(Arc<std::sync::Mutex<Vec<GenerateFrameInfo>>>);
impl Recorded {
fn hook(&self) -> Option<Box<dyn GenerateFrameHook>> {
Some(Box::new(self.clone()))
}
fn frames(&self) -> Vec<GenerateFrameInfo> {
self.0.lock().unwrap().clone()
}
}
impl GenerateFrameHook for Recorded {
fn annotate(&mut self, frame: &GenerateFrameInfo, keys: &mut GenerateFrameKeys) {
let mut frames = self.0.lock().unwrap();
keys.meta_info
.insert("hook_frame".into(), serde_json::json!(frames.len()));
frames.push(*frame);
}
}
/// A cumulative stream calls the hook once per frame it emits, in order:
/// each intermediate frame (one for a coalesced backlog) unfinished and
/// without a decode latency, then the terminal one with this frontend's
/// decode latency (a streamed request carries no scheduler measure). Each
/// frame carries its own call's keys beside its cumulative text.
#[tokio::test]
async fn generate_hook_annotates_intermediate_cumulative_frames() {
let recorded = Recorded::default();
let (tx, rx) = mpsc::channel(8);
let (call, mut request) = timed_call(10, rx);
request.hook = recorded.hook();
let stream = generation_event_stream(vec![(call, request)], false, false);
futures::pin_mut!(stream);
tx.send(frame(10, "a")).await.unwrap();
let first = parse(&stream.next().await.unwrap());
tx.send(frame(10, "b")).await.unwrap();
tx.send(frame(10, "c")).await.unwrap();
let backlog = parse(&stream.next().await.unwrap());
tx.send(done(10, "!")).await.unwrap();
let terminal = parse(&stream.next().await.unwrap());
assert_eq!(stream.next().await.unwrap(), "[DONE]");
for (position, (frame, text)) in [(&first, "a"), (&backlog, "abc"), (&terminal, "abc!")]
.into_iter()
.enumerate()
{
assert_eq!(frame["text"], text);
assert_eq!(frame["meta_info"]["hook_frame"], position, "{text}");
}
let frames = recorded.frames();
let finished: Vec<bool> = frames.iter().map(|frame| frame.finished).collect();
assert_eq!(finished, [false, false, true]);
assert!(
frames[..2]
.iter()
.all(|frame| frame.decode_latency.is_none())
);
let decode_latency = frames[2].decode_latency.expect("terminal").as_secs_f64();
let e2e_latency = terminal["meta_info"]["e2e_latency"].as_f64().unwrap();
assert!(
decode_latency <= e2e_latency,
"{decode_latency} > {e2e_latency}"
);
}
/// The scheduler's decode latency, as Python's `push_generation` frames it
/// (`testdata/frame_metadata.json`), reaches the hook on a unary request's
/// terminal frame, also from behind logprob columns, in place of this
/// frontend's own measure (zero here: the terminal is the first output).
#[tokio::test]
async fn scheduler_decode_latency_reaches_the_hook() {
for (name, rid) in [("batch", "done"), ("logprob_batch", "logprobs")] {
let (case, events) = crate::api_server::core::frame_metadata_case(name);
let mut event = events
.into_iter()
.find(|event| event.rid.as_str() == rid)
.unwrap();
event.rid = Rid::from("10".to_owned());
let expected = case["decode_latencies"][rid]
.as_f64()
.map(Duration::from_secs_f64);
assert!(expected.is_some(), "{name}: the fixture times {rid}");
let recorded = Recorded::default();
let (tx, rx) = mpsc::channel(1);
let (mut call, mut request) = timed_call(10, rx);
request.hook = recorded.hook();
tx.send(ResponseItem::Done(event)).await.unwrap();
let (status, item) = drain_unary(&mut call, "client-rid", request).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
serde_json::to_value(&item).unwrap()["meta_info"]["hook_frame"],
0
);
let frames = recorded.frames();
assert_eq!(frames.len(), 1, "{name}");
assert!(frames[0].finished, "{name}");
assert_eq!(frames[0].decode_latency, expected, "{name}");
}
}
/// The native unary response uses the same names and meanings as Python's
/// TokenizerManager metadata, and adds e2e_latency only on the terminal
/// result. The timer is seconds from normalized-request acceptance through
@@ -928,7 +1372,12 @@ mod tests {
time_to_first_token: None,
e2e_latency: None,
};
let (status, item) = drain_unary(&mut call, "client-rid", timing).await;
let request = RequestState {
timing,
scheduler_decode_latency: None,
hook: None,
};
let (status, item) = drain_unary(&mut call, "client-rid", request).await;
let value = serde_json::to_value(&item).unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(value["meta_info"]["id"], "client-rid");
@@ -1247,6 +1247,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: CoreMetadata::from(args.as_ref()),
metrics: None,
},
);
let formatter =
@@ -1257,6 +1258,7 @@ mod tests {
generation_policy: crate::message::request::GenerationPolicy::from(args.as_ref()),
server_args: args,
chat_formatter: Some(super::super::ChatFormatter::HuggingFace(formatter)),
generate_hook: None,
});
let responder = tokio::spawn(async move {
let TmEvent::Intake { request, admission } = intake_rx.recv_async().await.unwrap()
@@ -35,6 +35,7 @@ pub(super) fn core_handle() -> CoreHandle {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::from(server_args().as_ref()),
metrics: None,
},
)
}
@@ -112,6 +113,7 @@ pub(super) fn app_state(core: CoreHandle) -> Arc<super::AppState> {
generation_policy: crate::message::request::GenerationPolicy::from(server_args.as_ref()),
server_args,
chat_formatter: None,
generate_hook: None,
})
}
@@ -132,6 +134,7 @@ pub(super) fn frontend_closed() -> CoreHandle {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::from(server_args().as_ref()),
metrics: None,
},
)
}
@@ -157,6 +160,7 @@ fn answering_app_state() -> Arc<super::AppState> {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: crate::api_server::core::CoreMetadata::from(server_args().as_ref()),
metrics: None,
},
);
let server_args = server_args();
@@ -169,6 +173,7 @@ fn answering_app_state() -> Arc<super::AppState> {
spec: super::template::builtin_template("chatml").unwrap(),
},
))),
generate_hook: None,
})
}
@@ -271,6 +271,7 @@ mod tests {
is_disaggregation: false,
mm_limits: Default::default(),
metadata: CoreMetadata::from(server_args.as_ref()),
metrics: None,
},
),
generation_policy: crate::message::request::GenerationPolicy::from(
@@ -286,6 +287,7 @@ mod tests {
)
.unwrap(),
),
generate_hook: None,
})
}
+14 -5
View File
@@ -17,12 +17,16 @@ mod multi_modality;
mod tokenizer_manager;
mod utils;
pub use api_server::http::generate_hook::{
GenerateFrameHook, GenerateFrameInfo, GenerateFrameKeys, GenerateHook,
};
pub use message::config::{
DefaultSamplingParams, DisaggregationMode, MetricsConfig, MmFamily, MmResample, MmSpec,
ModelConfig, RustServerServerArgs, ServerArgs,
};
pub use message::multimodal::{MediaHints, MmItem};
pub use message::request::MmData;
pub use message::sampling::SamplingParams;
pub use message::types::TokenIds;
pub use multi_modality::encoded::{
MRope, MmEncodedEntry, MmEncodedItem, MmMetaValue, MmModality, MmTokenIds,
@@ -152,7 +156,7 @@ fn buffer_to_py(py: Python<'_>, buffer: message::buffers::Buffer) -> PyResult<(S
}
/// Wraps the HTTP app before it serves: an embedding package's own middleware
/// or routes ([`Server::start_with_http_layer`]). It wraps every route but the
/// or routes ([`Server::start_with_extensions`]). It wraps every route but the
/// PD bootstrap registry's, inside the access log.
pub type HttpLayer = Arc<dyn Fn(axum::Router) -> axum::Router + Send + Sync>;
@@ -188,7 +192,7 @@ impl Server {
stage_channel_cap: usize,
cores: Option<Vec<usize>>,
) -> PyResult<Self> {
Self::start_with_http_layer(
Self::start_with_extensions(
server_args,
port_offset,
to_scheduler_cap,
@@ -196,6 +200,7 @@ impl Server {
stage_channel_cap,
cores,
None,
None,
)
}
@@ -288,10 +293,12 @@ impl Server {
}
impl Server {
/// [`Server::start`], with `http_layer` wrapping the HTTP app (see
/// [`HttpLayer`]).
/// [`Server::start`] with a model package's extensions, the boot of the
/// package's own server class: `http_layer` wraps the HTTP app (see
/// [`HttpLayer`]), and `generate_hook` adds the package's keys to native
/// `/generate` frames (see [`GenerateHook`]).
#[allow(clippy::too_many_arguments)]
pub fn start_with_http_layer(
pub fn start_with_extensions(
server_args: ServerArgs,
port_offset: Option<u16>,
to_scheduler_cap: usize,
@@ -299,6 +306,7 @@ impl Server {
stage_channel_cap: usize,
cores: Option<Vec<usize>>,
http_layer: Option<HttpLayer>,
generate_hook: Option<Arc<dyn GenerateHook>>,
) -> PyResult<Self> {
// `server_args` already arrived typed (pyo3 rejected any missing/extra/
// mistyped field when Python constructed it); only value checks remain.
@@ -325,6 +333,7 @@ impl Server {
metrics_listener: port_offset.unwrap_or(0) == 0,
},
server_args: std::sync::Arc::new(server_args),
generate_hook,
};
let rt =
runtime::start(cfg, http_layer).map_err(|e| value_error("runtime start failed", e))?;
+65 -4
View File
@@ -68,6 +68,8 @@ pub struct RuntimeConfig {
/// config-endpoint metadata). `Arc` so cloning the config (and, downstream,
/// each `AppState`) is cheap; immutable after construction.
pub server_args: Arc<ServerArgs>,
/// A model package's keys for native `/generate` frames.
pub generate_hook: Option<Arc<dyn crate::GenerateHook>>,
}
impl Default for RuntimeConfig {
@@ -75,6 +77,7 @@ impl Default for RuntimeConfig {
Self {
rust_server_args: RustServerServerArgs::default(),
server_args: Arc::new(ServerArgs::default()),
generate_hook: None,
}
}
}
@@ -480,16 +483,74 @@ impl<'py> pyo3::FromPyObject<'_, 'py> for DeploymentInfo {
#[derive(Clone, Debug)]
pub struct MetricsConfig {
/// `PROMETHEUS_MULTIPROC_DIR`: every process's prometheus_client files,
/// which `/metrics` merges on each scrape.
/// which `/metrics` merges on each scrape. The frontend writes its own
/// request metrics there too.
pub multiproc_dir: String,
/// The labels on every request metric (`tokenizer_metric_labels()`).
pub labels: BTreeMap<String, String>,
/// `--enable-priority-scheduling`: the `priority` label carries each
/// request's priority.
pub priority_label: bool,
/// Bucket overrides (`--bucket-*`, resolved `--*-tokens-buckets` rules);
/// `None` keeps Python's default buckets.
pub bucket_time_to_first_token: Option<Vec<f64>>,
pub bucket_inter_token_latency: Option<Vec<f64>>,
pub bucket_e2e_request_latency: Option<Vec<f64>>,
pub bucket_prompt_tokens: Option<Vec<f64>>,
pub bucket_generation_tokens: Option<Vec<f64>>,
}
#[pyo3::pymethods]
impl MetricsConfig {
#[new]
#[pyo3(signature = (*, multiproc_dir))]
fn py_new(multiproc_dir: String) -> Self {
Self { multiproc_dir }
#[pyo3(signature = (*,
multiproc_dir,
labels,
priority_label,
bucket_time_to_first_token,
bucket_inter_token_latency,
bucket_e2e_request_latency,
bucket_prompt_tokens,
bucket_generation_tokens,
))]
#[allow(clippy::too_many_arguments)]
fn py_new(
multiproc_dir: String,
labels: BTreeMap<String, String>,
priority_label: bool,
bucket_time_to_first_token: Option<Vec<f64>>,
bucket_inter_token_latency: Option<Vec<f64>>,
bucket_e2e_request_latency: Option<Vec<f64>>,
bucket_prompt_tokens: Option<Vec<f64>>,
bucket_generation_tokens: Option<Vec<f64>>,
) -> Self {
Self {
multiproc_dir,
labels,
priority_label,
bucket_time_to_first_token,
bucket_inter_token_latency,
bucket_e2e_request_latency,
bucket_prompt_tokens,
bucket_generation_tokens,
}
}
}
#[cfg(test)]
impl MetricsConfig {
/// Default buckets, no labels, files under `dir`.
pub(crate) fn for_dir(dir: &std::path::Path) -> Self {
Self {
multiproc_dir: dir.to_string_lossy().into_owned(),
labels: BTreeMap::new(),
priority_label: false,
bucket_time_to_first_token: None,
bucket_inter_token_latency: None,
bucket_e2e_request_latency: None,
bucket_prompt_tokens: None,
bucket_generation_tokens: None,
}
}
}
+133 -9
View File
@@ -47,7 +47,9 @@ pub type ResponseSource = mpsc::Receiver<ResponseItem>;
/// transport adapter sees them.
#[derive(Debug)]
pub enum ResponseItem {
/// An intermediate streamed generation step (only sent for streaming reqs).
/// An intermediate generation step: every streamed step of a streaming
/// request, and every `SGLANG_FORCE_STREAM_INTERVAL` tokens of a
/// non-streaming one.
Frame(ChunkEvent),
/// The final generation step.
Done(ChunkEvent),
@@ -187,6 +189,36 @@ pub struct BatchHeader {
pub hidden_reqlens: Vec<u32>,
#[serde(default)]
pub hidden_poslens: Vec<u32>,
/// The scheduler's `weight_versions`: a finished request's spans, `None`
/// for one still running; empty when no request in the batch finished.
#[serde(default)]
pub weight_versions: Vec<Option<Vec<WeightVersionSpan>>>,
/// Media prompt tokens per modality; empty on a text-only model.
#[serde(default)]
pub image_tokens: Vec<u32>,
#[serde(default)]
pub audio_tokens: Vec<u32>,
#[serde(default)]
pub video_tokens: Vec<u32>,
/// A finished non-streaming request's decode latency in seconds, measured
/// by the scheduler (`None` for every other row).
#[serde(default)]
pub decode_latencies: Vec<Option<f64>>,
/// Rows the scheduler ended outside the output stream
/// (`RustServer.push_abort`), which Python's TokenizerManager answers in
/// `_handle_abort_req` without recording request metrics; empty for an
/// output-stream batch.
#[serde(default)]
pub abort_outputs: Vec<bool>,
}
/// The completion tokens `[start, end)` one weight version produced, as the
/// scheduler's `WeightVersionSpan` encodes it: `[version, start, end]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WeightVersionSpan {
pub version: String,
pub start: u64,
pub end: u64,
}
/// Read a request's flat logprob column (`l` val/idx pairs) from `data` at cursors
@@ -303,6 +335,12 @@ pub fn for_each_chunk(body: &[u8], mut route: impl FnMut(ChunkEvent)) -> Decoded
|| !per_req_ok(&h.out_tokids_lp_reqlens)
|| !per_req_ok(&h.in_tokids_lp_reqlens)
|| !per_req_ok(&h.hidden_reqlens)
|| !(h.weight_versions.is_empty() || h.weight_versions.len() == n)
|| !per_req_ok(&h.image_tokens)
|| !per_req_ok(&h.audio_tokens)
|| !per_req_ok(&h.video_tokens)
|| !(h.decode_latencies.is_empty() || h.decode_latencies.len() == n)
|| !(h.abort_outputs.is_empty() || h.abort_outputs.len() == n)
{
reject!()
}
@@ -388,11 +426,40 @@ pub fn for_each_chunk(body: &[u8], mut route: impl FnMut(ChunkEvent)) -> Decoded
// which is exactly what the streaming decode avoids.
let mut decode_one = |i: usize| -> Option<ChunkEvent> {
let token_ids = take_i64(data, &mut c_ids, lens_i(&h.tok_lens, i))?;
// The metadata columns after the extras: per-row values, present when a
// request in the frame finished or the model is multimodal.
let weight_versions = h
.weight_versions
.get_mut(i)
.and_then(Option::take)
.unwrap_or_default();
let image_tokens = h.image_tokens.get(i).copied().unwrap_or(0);
let audio_tokens = h.audio_tokens.get(i).copied().unwrap_or(0);
let video_tokens = h.video_tokens.get(i).copied().unwrap_or(0);
let decode_latency = h.decode_latencies.get(i).copied().flatten();
let abort_output = h.abort_outputs.get(i).copied().unwrap_or(false);
// Plain decode frame (no request in the batch asked for logprobs/hidden):
// the extras columns are all zero-width, so skip reading them entirely.
// the extras columns are all zero-width, so skip reading them entirely,
// and box the metadata only for a row that carries any.
let extras = if !has_extras {
None
let has_metadata = !weight_versions.is_empty()
|| image_tokens != 0
|| audio_tokens != 0
|| video_tokens != 0
|| decode_latency.is_some()
|| abort_output;
has_metadata.then(|| {
Box::new(ChunkExtras {
weight_versions,
image_tokens,
audio_tokens,
video_tokens,
decode_latency,
abort_output,
..Default::default()
})
})
} else {
let (out_lp_val, out_lp_idx) =
take_flat(data, &mut c_olp_v, &mut c_olp_i, lens_i(&h.out_lp_lens, i))?;
@@ -459,6 +526,12 @@ pub fn for_each_chunk(body: &[u8], mut route: impl FnMut(ChunkEvent)) -> Decoded
in_tid_lens,
hidden_val,
hidden_lens,
weight_versions,
image_tokens,
audio_tokens,
video_tokens,
decode_latency,
abort_output,
// Explicit, NOT `..Default::default()` — same reason as `ChunkEvent`
// below: a new column must fail to compile here until it is decoded.
out_lp_txt: Vec::new(),
@@ -601,14 +674,16 @@ pub struct ChunkEvent {
/// Set by the detok shard when the last of `token_ids` is a matched stop
/// token kept in `output_ids` but left out of `text`.
pub stop_token_trimmed: bool,
/// Logprob + hidden-state columns — `None` unless the request asked for them.
/// Boxed to keep the common token/text/finish frame small at large decode
/// batches (the decoder allocates it only when a column is non-empty).
/// Logprob + hidden-state columns — `None` unless the request asked for them —
/// and the rarer per-request metadata. Boxed to keep the common
/// token/text/finish frame small at large decode batches (the decoder
/// allocates it only when a column is non-empty).
pub extras: Option<Box<ChunkExtras>>,
}
/// Logprob + hidden-state columns for a [`ChunkEvent`], allocated only when the
/// request enabled logprobs / hidden states. Columnar `val`/`idx` (+ ragged `lens`)
/// request enabled logprobs / hidden states, finished (its weight-version spans)
/// or has media prompt tokens. Columnar `val`/`idx` (+ ragged `lens`)
/// buffers arrive pre-decode; the detok shard fills the parallel `*_txt` columns
/// when `return_text_in_logprobs` is set. In-process only — no serde (see
/// [`ChunkEvent`]).
@@ -639,6 +714,19 @@ pub struct ChunkExtras {
/// across chunks (the final message has the full set).
pub hidden_val: Vec<f32>,
pub hidden_lens: Vec<u32>,
/// Which weight version produced which output tokens; on a finished
/// request's chunk only.
pub weight_versions: Vec<WeightVersionSpan>,
/// Media prompt tokens per modality; zero for a text-only request.
pub image_tokens: u32,
pub audio_tokens: u32,
pub video_tokens: u32,
/// A finished non-streaming request's decode latency in seconds, from the
/// scheduler.
pub decode_latency: Option<f64>,
/// The scheduler ended this request outside the output stream (see
/// [`BatchHeader::abort_outputs`]).
pub abort_output: bool,
/// Decoded logprob token text (`return_text_in_logprobs`), parallel to the
/// `*_idx` buffers; empty when not requested (the tuple's text slot stays null).
pub out_lp_txt: Vec<String>,
@@ -650,8 +738,8 @@ pub struct ChunkExtras {
}
impl ChunkExtras {
/// True when no logprob / hidden column carries data — lets the decoder skip the
/// box allocation for the common (extras-free) frame.
/// True when no column carries data — lets the decoder skip the box
/// allocation for the common (extras-free) frame.
fn is_empty(&self) -> bool {
self.out_lp_val.is_empty()
&& self.in_lp_val.is_empty()
@@ -660,6 +748,12 @@ impl ChunkExtras {
&& self.out_tid_lens.is_empty()
&& self.in_tid_lens.is_empty()
&& self.hidden_lens.is_empty()
&& self.weight_versions.is_empty()
&& self.image_tokens == 0
&& self.audio_tokens == 0
&& self.video_tokens == 0
&& self.decode_latency.is_none()
&& !self.abort_output
}
}
@@ -1240,6 +1334,36 @@ mod tests {
assert_ne!(events[0].rid, events[1].rid);
}
/// A finished request's spans ride the frame without any logprob columns;
/// only rows that carry metadata get a `ChunkExtras` box.
#[test]
fn metadata_columns_box_only_the_rows_that_carry_them() {
let span = WeightVersionSpan {
version: "v".into(),
start: 0,
end: 1,
};
let header = BatchHeader {
rids: vec!["a".into(), "b".into(), "c".into()],
finish_reasons: vec![None, None, None],
prompt_tokens: vec![1, 1, 1],
tok_lens: vec![1, 1, 1],
cached_tokens: vec![0, 0, 0],
reasoning_tokens: vec![0, 0, 0],
weight_versions: vec![None, Some(vec![span.clone()]), None],
..Default::default()
};
let header = rmp_serde::to_vec(&header).unwrap();
let data: Vec<u8> = [1i64, 2, 3].iter().flat_map(|x| x.to_le_bytes()).collect();
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut events = Vec::new();
assert!(for_each_chunk(&framed[1..], |ev| events.push(ev)).ok);
assert!(events[0].extras.is_none() && events[2].extras.is_none());
let extras = events[1].extras.as_deref().unwrap();
assert_eq!(extras.weight_versions, [span]);
assert!(extras.out_lp_val.is_empty() && extras.image_tokens == 0);
}
/// The common frame must stay small: logprob/hidden columns are boxed behind
/// `ChunkExtras`. 152 since the matched stop token rides in the frame (#42637).
#[test]
+14 -8
View File
@@ -319,6 +319,19 @@ impl SamplingParams {
self.verify(vocab_size)
}
/// `temperature` and `top_k` as the scheduler samples with them, which
/// [`normalize`](Self::normalize) settles: a greedy temperature samples at
/// 1.0 from the top token, and `top_k = -1` is the whole vocabulary.
pub fn sampled_temperature_top_k(&self) -> (f64, i64) {
if (0.0..SAMPLING_EPS).contains(&self.temperature) {
(1.0, 1)
} else if self.top_k == -1 {
(self.temperature, TOP_K_ALL)
} else {
(self.temperature, self.top_k)
}
}
/// Python `__post_init__` (minus the null-to-default coercions, which the
/// null-tolerant deserializers above already did): copy the API aliases into
/// the internal fields and apply the greedy / `top_k` special cases.
@@ -337,14 +350,7 @@ impl SamplingParams {
if self.stop_token_ids.as_ref().is_some_and(|v| v.is_empty()) {
self.stop_token_ids = None;
}
if (0.0..SAMPLING_EPS).contains(&self.temperature) {
// Greedy: temperature ~0 → temperature=1.0, top_k=1.
self.temperature = 1.0;
self.top_k = 1;
}
if self.top_k == -1 {
self.top_k = TOP_K_ALL; // -1 disables top_k → whole vocabulary
}
(self.temperature, self.top_k) = self.sampled_temperature_top_k();
for constraint in [
&mut self.json_schema,
&mut self.regex,
+99 -4
View File
@@ -5,7 +5,8 @@
//!
//! With metrics enabled, every Python process writes its samples to files under
//! `PROMETHEUS_MULTIPROC_DIR`, and Python's `/metrics` merges those files per
//! scrape. Reading them here serves the same text without taking the GIL.
//! scrape. Reading them here serves the same text without taking the GIL, and
//! the frontend's own samples, written in the same format, merge with them.
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
@@ -13,8 +14,8 @@ use std::hash::Hash;
use std::io;
use std::path::Path;
/// One `MmapedDict` entry: the JSON key (Python's `mmap_key`), the value and
/// the timestamp (read only by `mostrecent` gauges).
/// One `MmapedDict` entry: the JSON key (see [`mmap_key`]), the value and the
/// timestamp (read only by `mostrecent` gauges).
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub key: String,
@@ -76,6 +77,70 @@ pub fn read_entries(data: &[u8]) -> Result<Vec<Entry>, String> {
Ok(entries)
}
/// Encode entries as `MmapedDict` writes them, up to the used length.
pub fn encode_entries<'a>(entries: impl IntoIterator<Item = &'a Entry>) -> Vec<u8> {
let mut out = vec![0u8; HEADER_LEN];
for entry in entries {
let key = entry.key.as_bytes();
out.extend_from_slice(&(key.len() as u32).to_le_bytes());
out.extend_from_slice(key);
out.resize(out.len() + padded_key_len(key.len()) - key.len(), b' ');
out.extend_from_slice(&entry.value.to_le_bytes());
out.extend_from_slice(&entry.timestamp.to_le_bytes());
}
let used = out.len() as u32;
out[..4].copy_from_slice(&used.to_le_bytes());
out
}
/// Python `mmap_key`: `json.dumps([metric_name, name, labels, help_text],
/// sort_keys=True)`, byte for byte.
pub fn mmap_key(metric_name: &str, name: &str, labels: &[(String, String)], help: &str) -> String {
let mut sorted: Vec<&(String, String)> = labels.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::from("[");
push_json_str(&mut out, metric_name);
out.push_str(", ");
push_json_str(&mut out, name);
out.push_str(", {");
for (i, (key, value)) in sorted.into_iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
push_json_str(&mut out, key);
out.push_str(": ");
push_json_str(&mut out, value);
}
out.push_str("}, ");
push_json_str(&mut out, help);
out.push(']');
out
}
/// A JSON string as Python's `json.dumps` writes it (`ensure_ascii=True`).
fn push_json_str(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
' '..='~' => out.push(c),
_ => {
let mut units = [0u16; 2];
for unit in c.encode_utf16(&mut units) {
let _ = write!(out, "\\u{unit:04x}");
}
}
}
}
out.push('"');
}
type Labels = Vec<(String, String)>;
/// One merged sample, labels in the order Python accumulates them.
@@ -503,6 +568,15 @@ pub fn read_dir_files(dir: &Path) -> io::Result<Vec<(String, Vec<u8>)>> {
Ok(files)
}
/// Replace `dir/file_name` with `data` (see [`encode_entries`]), atomically: a
/// scrape reads either the previous file or this one, never a partial write.
pub fn write_file(dir: &Path, file_name: &str, data: &[u8]) -> io::Result<()> {
// Not `*.db`, so a concurrent glob never lists the partial file.
let tmp = dir.join(format!("{file_name}.tmp"));
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, dir.join(file_name))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -552,6 +626,26 @@ mod tests {
assert_eq!(render(&families), text);
}
/// Re-encoding every fixture file reproduces Python's bytes up to the used
/// length (Python preallocates the rest), and each key is what Python's
/// `mmap_key` writes for its parsed parts.
#[test]
fn encodes_files_and_keys_as_python_does() {
let (files, _) = fixture();
for (name, data) in &files {
let entries = read_entries(data).unwrap();
let used = u32::from_le_bytes(data[..4].try_into().unwrap()) as usize;
assert_eq!(encode_entries(&entries), data[..used], "{name}");
for entry in &entries {
let (metric_name, sample_name, labels, help) = parse_key(&entry.key).unwrap();
assert_eq!(
mmap_key(&metric_name, &sample_name, &labels, &help),
entry.key
);
}
}
}
#[test]
fn corrupt_files_are_skipped() {
let (files, _) = fixture();
@@ -603,7 +697,8 @@ mod tests {
let dir = std::env::temp_dir().join(format!("prom-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (files, _) = fixture();
std::fs::write(dir.join(&files[0].0), &files[0].1).unwrap();
let entries = read_entries(&files[0].1).unwrap();
write_file(&dir, &files[0].0, &encode_entries(&entries)).unwrap();
std::fs::write(dir.join(".hidden.db"), b"").unwrap();
std::fs::write(dir.join("counter_1.db.tmp"), b"").unwrap();
let listed = read_dir_files(&dir).unwrap();
+17 -3
View File
@@ -132,6 +132,15 @@ pub fn start(cfg: RuntimeConfig, http_layer: Option<HttpLayer>) -> Result<Runtim
let (shutdown_tx, shutdown_rx) = flume::unbounded::<()>();
let mut threads = Vec::new();
let plan = plan_cores(&cfg);
// Validated before anything is spawned, so a bad bucket list fails startup
// cleanly.
let metrics = match &cfg.server_args.metrics {
Some(config) => Some(Arc::new(crate::api_server::core::FrontendMetrics::new(
config,
cfg.server_args.disaggregation_mode,
)?)),
None => None,
};
// --- rings (Rust ↔ Python) ---
let (to_scheduler_tx, to_scheduler_rx): (ToSchedulerTx, ToSchedulerRx) =
@@ -318,6 +327,7 @@ pub fn start(cfg: RuntimeConfig, http_layer: Option<HttpLayer>) -> Result<Runtim
is_disaggregation: cfg.server_args.is_disaggregation(),
mm_limits: cfg.server_args.limit_mm_data_per_request.clone(),
metadata: crate::api_server::core::CoreMetadata::from(cfg.server_args.as_ref()),
metrics,
},
);
@@ -363,6 +373,7 @@ pub fn start(cfg: RuntimeConfig, http_layer: Option<HttpLayer>) -> Result<Runtim
cfg.server_args.clone(),
cfg.rust_server_args.metrics_listener,
http_layer,
cfg.generate_hook.clone(),
shutdown_rx.clone(),
hicache_manager,
);
@@ -423,6 +434,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(test_server_args()),
generate_hook: None,
}
}
@@ -511,6 +523,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(server_args),
generate_hook: None,
};
// Bind is synchronous in `start`, so the port is already accepting.
let rt = start(cfg, None).expect("start runtime");
@@ -547,6 +560,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(server_args),
generate_hook: None,
};
let rt = start(cfg, None).expect("start runtime");
@@ -587,9 +601,7 @@ mod tests {
let mut cfg = test_config(addr, None);
cfg.rust_server_args.metrics_listener = metrics_listener;
cfg.server_args = Arc::new(ServerArgs {
metrics: Some(crate::message::config::MetricsConfig {
multiproc_dir: dir.to_string_lossy().into_owned(),
}),
metrics: Some(crate::message::config::MetricsConfig::for_dir(&dir)),
..test_server_args()
});
let rt = start(cfg, None).expect("start runtime");
@@ -623,6 +635,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(server_args),
generate_hook: None,
};
let rt = start(cfg, None).expect("start runtime");
@@ -726,6 +739,7 @@ mod tests {
..Default::default()
},
server_args: Arc::new(server_args),
generate_hook: None,
};
let err = match start(cfg, None) {
Ok(_) => panic!("bind conflict must fail startup, got Ok"),
+72
View File
@@ -0,0 +1,72 @@
{
"batch": {
"header": "dc001792a4646f6e65a772756e6e696e679282a474797065a66c656e677468a66c656e67746803c0920506920301920000920000909090909090909090909090929293a36f6c64000293a36e65770203c092080092000292000092cb3fd0000000000000c0",
"data": "0100000000000000020000000000000003000000000000000400000000000000",
"decode_latencies": {
"done": 0.25
},
"meta_info": {
"done": {
"weight_version": "new",
"weight_versions": [
{
"version": "old",
"start": 0,
"end": 2
},
{
"version": "new",
"start": 2,
"end": 3
}
],
"image_tokens": 8
},
"running": {
"weight_version": "launch",
"audio_tokens": 2
}
}
},
"logprob_batch": {
"header": "dc001792a86c6f6770726f6273a5706c61696e9282a474797065a66c656e677468a66c656e67746802c09203049202019200009200009202009090909090909090909090929193a276310002c090909092cb3fe0000000000000c0",
"data": "070000000000000008000000000000000900000000000000000000bf000080be0700000008000000",
"decode_latencies": {
"logprobs": 0.5
},
"meta_info": {
"logprobs": {
"weight_version": "v1",
"weight_versions": [
{
"version": "v1",
"start": 0,
"end": 2
}
]
},
"plain": {
"weight_version": "launch"
}
}
},
"abort": {
"header": "dc001891a761626f727465649182a474797065a561626f7274a76d657373616765b641626f727420696e2077616974696e672071756575659102910091009100909090909090909090909090919293a36f6c64000293a36e657702099090909091c3",
"completion_tokens": 3,
"meta_info": {
"weight_version": "new",
"weight_versions": [
{
"version": "old",
"start": 0,
"end": 2
},
{
"version": "new",
"start": 2,
"end": 3
}
]
}
}
}
+395
View File
@@ -0,0 +1,395 @@
{
"labels": {
"model_name": "model",
"engine_type": "unified"
},
"families": [
{
"name": "sglang:startup_time_seconds",
"kind": "gauge",
"documentation": "Engine startup duration by phase in seconds.",
"labelnames": [
"model_name",
"engine_type",
"phase"
],
"buckets": null
},
{
"name": "sglang:startup_cuda_graph_time_seconds",
"kind": "gauge",
"documentation": "CUDA graph capture duration by phase in seconds.",
"labelnames": [
"model_name",
"engine_type",
"phase"
],
"buckets": null
},
{
"name": "sglang:prompt_tokens_total",
"kind": "counter",
"documentation": "Number of prefill tokens processed.",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": null
},
{
"name": "sglang:generation_tokens_total",
"kind": "counter",
"documentation": "Number of generation tokens processed.",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": null
},
{
"name": "sglang:spec_verify_calls_total",
"kind": "counter",
"documentation": "Number of speculative decoding verification calls.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": null
},
{
"name": "sglang:prompt_tokens_histogram",
"kind": "histogram",
"documentation": "Histogram of prompt token length.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": [
100.0,
300.0,
500.0,
700.0,
1000.0,
1500.0,
2000.0,
3000.0,
4000.0,
5000.0,
6000.0,
7000.0,
8000.0,
9000.0,
10000.0,
12500.0,
15000.0,
17500.0,
20000.0,
22500.0,
25000.0,
27500.0,
30000.0,
35000.0,
40000.0,
60000.0,
80000.0,
100000.0,
200000.0,
300000.0,
400000.0,
600000.0,
800000.0,
1000000.0,
1100000.0
]
},
{
"name": "sglang:uncached_prompt_tokens_histogram",
"kind": "histogram",
"documentation": "Histogram of uncached (compute) prompt token length.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": [
100.0,
300.0,
500.0,
700.0,
1000.0,
1500.0,
2000.0,
3000.0,
4000.0,
5000.0,
6000.0,
7000.0,
8000.0,
9000.0,
10000.0,
12500.0,
15000.0,
17500.0,
20000.0,
22500.0,
25000.0,
27500.0,
30000.0,
35000.0,
40000.0,
60000.0,
80000.0,
100000.0,
200000.0,
300000.0,
400000.0,
600000.0,
800000.0,
1000000.0,
1100000.0
]
},
{
"name": "sglang:generation_tokens_histogram",
"kind": "histogram",
"documentation": "Histogram of generation token length.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": [
100.0,
300.0,
500.0,
700.0,
1000.0,
1500.0,
2000.0,
3000.0,
4000.0,
5000.0,
6000.0,
7000.0,
8000.0,
9000.0,
10000.0,
12500.0,
15000.0,
17500.0,
20000.0,
22500.0,
25000.0,
27500.0,
30000.0,
35000.0,
40000.0,
60000.0,
80000.0,
100000.0,
200000.0,
300000.0,
400000.0,
600000.0,
800000.0,
1000000.0,
1100000.0
]
},
{
"name": "sglang:cached_tokens_total",
"kind": "counter",
"documentation": "Number of cached prompt tokens by source (device/host/storage).",
"labelnames": [
"model_name",
"engine_type",
"cache_source"
],
"buckets": null
},
{
"name": "sglang:num_requests_total",
"kind": "counter",
"documentation": "Number of requests processed.",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": null
},
{
"name": "sglang:get_loads_duration_seconds",
"kind": "histogram",
"documentation": "Time spent serving /v1/loads requests (seconds).",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": [
0.0001,
0.0005,
0.001,
0.005,
0.01,
0.05,
0.1,
0.5,
1.0
]
},
{
"name": "sglang:num_so_requests_total",
"kind": "counter",
"documentation": "Number of structured output requests processed.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": null
},
{
"name": "sglang:num_aborted_requests_total",
"kind": "counter",
"documentation": "Number of requests aborted.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": null
},
{
"name": "sglang:time_to_first_token_seconds",
"kind": "histogram",
"documentation": "Histogram of time to first token in seconds.",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": [
0.1,
0.2,
0.4,
0.6,
0.8,
1.0,
2.0,
4.0,
6.0,
8.0,
10.0,
20.0,
40.0,
60.0,
80.0,
100.0,
200.0,
400.0
]
},
{
"name": "sglang:inter_token_latency_seconds",
"kind": "histogram",
"documentation": "Histogram of inter-token latency in seconds.",
"labelnames": [
"model_name",
"engine_type"
],
"buckets": [
0.002,
0.004,
0.006,
0.008,
0.01,
0.015,
0.02,
0.025,
0.03,
0.035,
0.04,
0.06,
0.08,
0.1,
0.2,
0.4,
0.6,
0.8,
1.0,
2.0,
4.0,
6.0,
8.0
]
},
{
"name": "sglang:request_time_per_output_token_seconds",
"kind": "histogram",
"documentation": "Per-request TPOT in seconds: (finished_time - first_token_time) / (completion_tokens - 1).",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": [
0.002,
0.004,
0.006,
0.008,
0.01,
0.015,
0.02,
0.025,
0.03,
0.035,
0.04,
0.06,
0.08,
0.1,
0.2,
0.4,
0.6,
0.8,
1.0,
2.0,
4.0,
6.0,
8.0
]
},
{
"name": "sglang:e2e_request_latency_seconds",
"kind": "histogram",
"documentation": "Histogram of End-to-end request latency in seconds",
"labelnames": [
"model_name",
"engine_type",
"is_streaming"
],
"buckets": [
0.1,
0.2,
0.4,
0.6,
0.8,
1.0,
2.0,
4.0,
6.0,
8.0,
10.0,
20.0,
40.0,
60.0,
80.0,
100.0,
200.0,
400.0,
600.0,
1200.0,
1800.0,
2400.0
]
}
]
}
@@ -288,6 +288,9 @@ class TestResponseParity(CustomTestCase):
"output_token_logprobs_length",
"weight_version",
"weight_versions",
"image_tokens",
"audio_tokens",
"video_tokens",
],
)
@@ -160,10 +160,17 @@ def test_deployment_info_reports_listener_dp_rank(server_args, ranks, expected):
@pytest.mark.parametrize("enable_metrics", [False, True])
def test_typed_config_points_metrics_at_the_multiproc_dir(enable_metrics):
def test_typed_config_carries_tokenizer_metrics(enable_metrics):
extension = MagicMock()
with (
get_context().override_server_args(enable_metrics=enable_metrics),
get_context().override_server_args(
enable_metrics=enable_metrics,
served_model_name="m",
enable_priority_scheduling=True,
extra_metric_labels={"cluster": "c"},
bucket_inter_token_latency=[0.001, 0.01],
generation_tokens_buckets=["custom", "10", "1"],
),
get_parallel().override(attn_dp_rank=0, dp_rank=None),
patch.dict("os.environ", {"PROMETHEUS_MULTIPROC_DIR": "/tmp/prom"}),
patch.object(rust_extensions, "load_rust_extension", return_value=extension),
@@ -175,11 +182,26 @@ def test_typed_config_points_metrics_at_the_multiproc_dir(enable_metrics):
)
metrics = extension.ServerArgs.call_args.kwargs["metrics"]
if enable_metrics:
extension.MetricsConfig.assert_called_once_with(multiproc_dir="/tmp/prom")
assert metrics is extension.MetricsConfig.return_value
else:
if not enable_metrics:
assert metrics is None
return
assert metrics is extension.MetricsConfig.return_value
extension.MetricsConfig.assert_called_once_with(
multiproc_dir="/tmp/prom",
labels={
"model_name": "m",
"engine_type": "unified",
"priority": "",
"cluster": "c",
},
priority_label=True,
bucket_time_to_first_token=None,
bucket_inter_token_latency=[0.001, 0.01],
bucket_e2e_request_latency=None,
# The default rule keeps the Rust side's defaults; others resolve here.
bucket_prompt_tokens=None,
bucket_generation_tokens=[1.0, 10.0],
)
@pytest.mark.parametrize(
@@ -1,5 +1,6 @@
import dataclasses
import pickle
import time
import unittest
from array import array
from types import SimpleNamespace
@@ -105,6 +106,7 @@ def _accumulator(
current_weight_version="default",
return_sampling_mask=False,
request_level_keys=frozenset(),
rust_server_mode=False,
):
return _GenerationStreamAccumulator(
return_logprob=False,
@@ -119,24 +121,30 @@ def _accumulator(
get_cached_tokens_details=lambda req: None,
is_request_level_customized_info_key=request_level_keys.__contains__,
current_weight_version=current_weight_version,
rust_server_mode=rust_server_mode,
)
def _enter_streamer_scope(test):
"""Patch what `SchedulerOutputStreamer` reads per call, for `test`."""
serving_patch = patch(
"sglang.srt.managers.scheduler_components.output_streamer.get_serving",
return_value=SimpleNamespace(stream_interval=1, weight_version="default"),
)
observability_patch = patch(
"sglang.srt.managers.scheduler_components.output_streamer.get_observability",
return_value=SimpleNamespace(enable_request_time_stats_logging=False),
)
serving_patch.start()
observability_patch.start()
enter_scope(test, published_topology(ranks={"dp_rank": 0}))
test.addCleanup(serving_patch.stop)
test.addCleanup(observability_patch.stop)
class TestOutputStreamerCustomizedInfo(unittest.TestCase):
def setUp(self):
serving_patch = patch(
"sglang.srt.managers.scheduler_components.output_streamer.get_serving",
return_value=SimpleNamespace(stream_interval=1, weight_version="default"),
)
observability_patch = patch(
"sglang.srt.managers.scheduler_components.output_streamer.get_observability",
return_value=SimpleNamespace(enable_request_time_stats_logging=False),
)
serving_patch.start()
observability_patch.start()
enter_scope(self, published_topology(ranks={"dp_rank": 0}))
self.addCleanup(serving_patch.stop)
self.addCleanup(observability_patch.stop)
_enter_streamer_scope(self)
def test_customized_info_is_padded_for_mixed_batches(self):
accumulator = _accumulator()
@@ -501,6 +509,56 @@ class TestOutputStreamerWeightVersions(unittest.TestCase):
self.assertIsNone(payload.weight_versions)
class TestOutputStreamerDecodeLatency(unittest.TestCase):
def setUp(self):
_enter_streamer_scope(self)
def test_rust_mode_times_finished_non_streaming_requests(self):
"""In Rust mode a finished non-streaming request's decode latency runs
from the prefill stamp, which Python's TokenizerManager reads only with
metrics on, and the scheduler hands it to the Rust frontend by payload
row with the batch; other requests, and Python mode, record none."""
stamp = time.perf_counter() - 0.5
def req(rid, *, stream=False, finished=True, metrics=True, stamp=stamp):
req = _FakeReq(rid, [1], finished=finished)
req.stream = stream
req.time_stats = SimpleNamespace(
enable_metrics=metrics, prefill_finished_time=stamp
)
return req
rust_server = Mock()
streamer = SchedulerOutputStreamer(
send_to_detokenizer=None,
tree_cache=None,
server_args=SimpleNamespace(),
is_generation=True,
spec_algorithm=SpeculativeAlgorithm.NONE,
disaggregation_mode=DisaggregationMode.NULL,
enable_hicache_storage=lambda: False,
rust_server=rust_server,
)
streamer._stream_output_generation(
[
req("streaming", stream=True),
req("timed"),
req("running", finished=False),
req("no_metrics", metrics=False),
req("no_stamp", stamp=0.0),
],
False,
)
payload, decode_latencies = rust_server.push_generation.call_args.args
self.assertEqual(payload.rids[1], "timed")
self.assertEqual(list(decode_latencies), [1])
self.assertGreaterEqual(decode_latencies[1], 0.5)
python_mode = _accumulator()
python_mode.accept(req=req("timed"))
self.assertEqual(python_mode.decode_latencies, {})
class TestOutputStreamerStreamInterval(unittest.TestCase):
def test_sends_once_interval_tokens_are_unsent(self):
"""With stream_interval=4, the first token goes out at once, then a chunk
@@ -1,6 +1,9 @@
"""Model packages use one extension for server arguments and worker startup."""
import json
import os
import unittest
from array import array
from contextlib import contextmanager
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch, sentinel
@@ -15,6 +18,137 @@ from sglang.srt.rust_server.server import RustServer # noqa: E402
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
# The Rust frontend decodes these headers and renders their `meta_info` keys
# (rust/sglang-server/src/api_server/core/frame.rs); they must match Python.
RUST_FRAME_METADATA_FIXTURE = os.path.join(
os.path.dirname(__file__),
*[os.pardir] * 6,
"rust/sglang-server/testdata/frame_metadata.json",
)
def build_rust_frame_metadata_fixture() -> dict:
"""The headers `push_generation` and `push_abort` send for the weight-version,
media-count and decode-latency columns, and the `meta_info` keys Python's
TokenizerManager derives from the same values, with "launch" as the
server's weight version.
Regenerate the fixture with ``json.dump(build_rust_frame_metadata_fixture(),
f, indent=2)`` after changing either side."""
from sglang.srt.managers.io_struct import AbortReq
from sglang.srt.utils.weight_versions import (
WeightVersionSpan,
add_weight_versions_to_meta_info,
)
def meta_info(spans, completion_tokens, image=0, audio=0, video=0):
# TokenizerManager._handle_batch_output / _handle_abort_req.
meta = {"weight_version": "launch"}
if spans is not None:
add_weight_versions_to_meta_info(meta, spans, completion_tokens)
for key, count in (
("image_tokens", image),
("audio_tokens", audio),
("video_tokens", video),
):
if count:
meta[key] = count
return meta
no_extras = dict.fromkeys(
[
f"{family}_{part}"
for family in (
"output_token_logprobs",
"input_token_logprobs",
"output_top_logprobs",
"input_top_logprobs",
"output_token_ids_logprobs",
"input_token_ids_logprobs",
)
for part in ("val", "idx")
]
+ ["output_hidden_states"]
)
def push_generation(multimodal, decode_latencies, **columns):
server = RustServer(Mock(), http_port=0)
server._multimodal_enabled = multimodal
payload = SimpleNamespace(**{**no_extras, **columns})
server.push_generation(payload, decode_latencies)
header, data = server.server.push_decode_result_batch.call_args.args
return {
"header": header.hex(),
"data": b"".join(data).hex(),
"decode_latencies": {
columns["rids"][row]: latency
for row, latency in decode_latencies.items()
},
}
swapped = [
WeightVersionSpan(version="old", start=0, end=2),
WeightVersionSpan(version="new", start=2, end=3),
]
batch = push_generation(
True,
{0: 0.25},
rids=["done", "running"],
finished_reasons=[{"type": "length", "length": 3}, None],
prompt_tokens=[5, 6],
output_ids=[array("q", [1, 2, 3]), array("q", [4])],
cached_tokens=[0, 0],
reasoning_tokens=[0, 0],
weight_versions=[swapped, None],
image_tokens=[8, 0],
audio_tokens=[0, 2],
video_tokens=[0, 0],
)
batch["meta_info"] = {
"done": meta_info(swapped, 3, image=8),
"running": meta_info(None, 1, audio=2),
}
# A text model's frame with output logprobs: the spans and the decode
# latency follow the extras columns rather than their padding.
single = [WeightVersionSpan(version="v1", start=0, end=2)]
logprob_batch = push_generation(
False,
{0: 0.5},
rids=["logprobs", "plain"],
finished_reasons=[{"type": "length", "length": 2}, None],
prompt_tokens=[3, 4],
output_ids=[array("q", [7, 8]), array("q", [9])],
cached_tokens=[0, 0],
reasoning_tokens=[0, 0],
weight_versions=[single, None],
image_tokens=[0, 0],
audio_tokens=[0, 0],
video_tokens=[0, 0],
output_token_logprobs_val=[[-0.5, -0.25], []],
output_token_logprobs_idx=[[7, 8], []],
)
logprob_batch["meta_info"] = {
"logprobs": meta_info(single, 2),
"plain": meta_info(None, 1),
}
# The scheduler's spans run past the three tokens the client received.
aborted = [
WeightVersionSpan(version="old", start=0, end=2),
WeightVersionSpan(version="new", start=2, end=9),
]
server = RustServer(Mock(), http_port=0)
req = SimpleNamespace(origin_input_ids=[1, 2], cached_tokens=0, reasoning_tokens=0)
server.push_abort(AbortReq(rid="aborted", weight_versions=aborted), req)
abort_header, _ = server.server.push_decode_result_batch.call_args.args
return {
"batch": batch,
"logprob_batch": logprob_batch,
"abort": {
"header": abort_header.hex(),
"completion_tokens": 3,
"meta_info": meta_info(aborted, 3),
},
}
@contextmanager
def launch_context(server_cls):
@@ -68,6 +202,10 @@ class TestRustServerExtension(CustomTestCase):
def _load_extension(cls):
return extension
@classmethod
def _server_kwargs(cls, scheduler):
return {"model_setting": sentinel.setting}
@classmethod
def _resolve_multimodal(cls, scheduler):
return None
@@ -88,7 +226,10 @@ class TestRustServerExtension(CustomTestCase):
default_max_new_tokens=128,
)
extension.Server.assert_called_once_with(
sentinel.args, cores=None, port_offset=1
sentinel.args,
cores=None,
port_offset=1,
model_setting=sentinel.setting,
)
instance.server.start_mm_workers.assert_called_once_with(sentinel.spec, 8)
self.assertIsInstance(instance, ModelServer)
@@ -122,8 +263,9 @@ class TestRustServerExtension(CustomTestCase):
def test_scheduler_aborts_reach_the_rust_egress(self):
"""An abort the scheduler sends outside the output stream becomes a
one-request terminal batch in Rust mode, keeping the request's counts;
without the Rust server it still goes to the tokenizer socket."""
one-request terminal batch in Rust mode, keeping the request's counts
and marked as outside the stream; without the Rust server it still
goes to the tokenizer socket."""
import msgspec
from sglang.srt.managers.io_struct import AbortReq
@@ -139,7 +281,9 @@ class TestRustServerExtension(CustomTestCase):
header, cols = server.server.push_decode_result_batch.call_args.args
self.assertEqual(
msgspec.msgpack.decode(header),
[["r#1"], [{"type": "abort", "message": "gone"}], [3], [0], [2], [1]],
[["r#1"], [{"type": "abort", "message": "gone"}], [3], [0], [2], [1]]
+ [[]] * 17
+ [[True]],
)
self.assertEqual(cols, [b""])
scheduler.ipc_channels.send_to_tokenizer.send_output.assert_not_called()
@@ -150,6 +294,10 @@ class TestRustServerExtension(CustomTestCase):
abort, req
)
def test_frame_metadata_fixture_matches_python(self):
with open(RUST_FRAME_METADATA_FIXTURE) as f:
self.assertEqual(json.load(f), build_rust_frame_metadata_fixture())
def test_launch_resolves_the_mm_pipeline_before_the_server_starts(self):
"""The listener answers once the server is constructed, so a model
without a Rust MM pipeline must be refused before that; a supported
@@ -1,10 +1,11 @@
"""Fixtures pinning the Rust frontend's ``/metrics`` to Python's.
The Rust server (``rust/sglang-server``) reads prometheus_client's multiprocess
files and renders them itself (``src/utils/prometheus.rs``). These tests fail if
prometheus_client's file format, merge or text rendering drifts from what the
committed fixtures record; regenerate them as each builder's docstring says,
then update the Rust side.
files and renders them itself (``src/utils/prometheus.rs``), and records the
``TokenizerMetricsCollector`` families itself (``src/api_server/core/metrics.rs``).
These tests fail if prometheus_client's file format, merge or text rendering, or
the collector's families, drift from what the committed fixtures record;
regenerate them as each builder's docstring says, then update the Rust side.
"""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -22,12 +23,15 @@ from prometheus_client import Counter, Gauge, Histogram, Summary, values
from prometheus_client.exposition import generate_latest
from prometheus_client.multiprocess import MultiProcessCollector
from sglang.srt.observability.metrics_collector import TokenizerMetricsCollector
from sglang.srt.runtime_context import get_context
from sglang.test.test_utils import CustomTestCase
TESTDATA = os.path.join(
os.path.dirname(__file__), *[os.pardir] * 4, "rust/sglang-server/testdata"
)
MULTIPROCESS_FIXTURE = os.path.join(TESTDATA, "prometheus_multiprocess.json")
TOKENIZER_FIXTURE = os.path.join(TESTDATA, "tokenizer_metrics.json")
def _write_process(directory: str, pid: int, now: float, observe) -> None:
@@ -135,7 +139,46 @@ def _render_python(files: list) -> str:
return generate_latest(_Files()).decode()
def build_rust_tokenizer_metrics_fixture() -> dict:
"""Every family ``TokenizerMetricsCollector`` registers under the default
server args, as its constructor arguments. Regenerate the fixture with
``json.dump(build_rust_tokenizer_metrics_fixture(), f, indent=2)``."""
families = []
def recorder(kind):
def register(name, documentation, labelnames=(), buckets=None, **_):
families.append(
{
"name": name,
"kind": kind,
"documentation": documentation,
"labelnames": list(labelnames),
"buckets": (
None if buckets is None else [float(b) for b in buckets]
),
}
)
return mock.MagicMock()
return staticmethod(register)
class _Recording(TokenizerMetricsCollector):
_counter_cls = recorder("counter")
_gauge_cls = recorder("gauge")
_histogram_cls = recorder("histogram")
labels = {"model_name": "model", "engine_type": "unified"}
with get_context().override_server_args():
_Recording(labels=labels)
return {"labels": labels, "families": families}
class TestRustMetricsFixtures(CustomTestCase):
def test_tokenizer_metrics_fixture_matches_python(self):
with open(TOKENIZER_FIXTURE) as f:
fixture = json.load(f)
self.assertEqual(fixture, build_rust_tokenizer_metrics_fixture())
def test_multiprocess_fixture_matches_python(self):
with open(MULTIPROCESS_FIXTURE) as f:
fixture = json.load(f)