support rust sglang server (#29799)

This commit is contained in:
Rain Jiang
2026-07-31 11:56:31 -07:00
committed by GitHub
parent 77c77a3da8
commit 4af8ddb576
23 changed files with 1314 additions and 84 deletions
+392
View File
@@ -0,0 +1,392 @@
"""Embedded Rust server lifecycle for the scheduler.
The Rust server replaces the Python api-server + `TokenizerManager` +
`DetokenizerManager` stack (hence this module sits beside them in `managers/`),
running them as Rust threads inside the scheduler process. This wrapper keeps
all `SGLANG_RUST_SERVER` plumbing — startup, CPU-core partitioning, the
`server_args` blob, and control-response routing — out of `scheduler.py`. The
scheduler holds an `Optional[RustServer]` and delegates to it.
"""
from __future__ import annotations
import logging
import os
from array import array
from itertools import chain
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
import msgspec
from sglang.srt.managers.utils import (
MsgpackDecodeError,
compute_num_reserved_tokens,
msgpack_decode_explained,
)
from sglang.srt.utils.flatten import (
FlatPairColumns,
NestedRowColumns,
RaggedPairColumns,
)
from sglang.version import __version__
if TYPE_CHECKING:
from sglang.srt.managers.io_struct import BatchTokenIDOutput
from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.server._core import Server
logger = logging.getLogger(__name__)
class RustServer:
"""Owns the embedded multi-threaded Rust server (``sglang_server.Server``).
The server owns the api-server, tokenizermanager, tokenizer, and detokenizer
all implemented as Rust threads in scheduler process.
"""
def __init__(self, server: Server, max_per_poll: int = 256):
self.server = server
self._max_per_poll = max_per_poll
@classmethod
def launch(cls, scheduler: Scheduler) -> RustServer:
"""Start the embedded Rust server threads and bind the listen port.
The caller gates this (``SGLANG_RUST_SERVER`` + rank 0); this always
creates.
"""
from sglang.srt.server._core import Server
# Force turn off HF tokenizers rayon's unpinned global thread pool.
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
server_args = scheduler.server_args
# `TokenizerManager` merges these under each request's own sampling params
# (`{**preferred, **obj.sampling_params}`), and this server replaces that
# manager wholesale — so honouring the flag is not implemented here yet.
# Refuse rather than run: silently dropping it means generating with
# sampling the operator did not configure, and `/get_model_info` would go on
# advertising values no request ever receives.
if server_args.preferred_sampling_params:
raise ValueError(
"SGLANG_RUST_SERVER does not yet apply --preferred-sampling-params "
"(the Python TokenizerManager merges it into every request; the rust "
"ingress has no equivalent). Launch without SGLANG_RUST_SERVER, or "
"drop --preferred-sampling-params and send those values per request."
)
http_addr = f"{server_args.host}:{server_args.port}"
launch_cores, server_cores = cls._partition_cores()
server = Server(
# None -> run unpinned; the list carries the pinning decision.
cores=server_cores,
http_addr=http_addr,
server_args_json=cls._build_server_args(scheduler),
)
# Narrow the scheduler thread only after the server threads are launched.
if launch_cores is not None:
try:
# pid 0 == this thread (the scheduler event-loop / launch thread).
os.sched_setaffinity(0, set(launch_cores))
except OSError as e:
logger.warning("rust server: cannot pin scheduler launch thread: %s", e)
logger.info(
"SGLANG_RUST_SERVER enabled, Rust server listen on %s",
http_addr,
)
return cls(server)
def wait_ingress(self, timeout_ms: int) -> None:
"""Block until a request is pushed into the in-process ring or the timeout
elapses.
"""
self.server.wait_ingress(timeout_ms)
def drain(self, max_recv: int) -> List[Any]:
"""Ingress: non-blocking drain of the in-process ring → list of decoded
request objects. The scheduler's request receiver calls this instead of
polling the zmq socket when `rust_server_mode` is set.
The transfer is **columnar**: `recv_requests` returns an `IngressBatch`
of scalar msgpack `headers` (with `input_ids` omitted) plus one
concatenated raw int64 `data` buffer and per-request `lengths`, so the
large `input_ids` lists never go through msgpack. Each header is `msgpack_decode`d (yielding
the same `TokenizedGenerateReqInput` / control objects the zmq path
produces, so the IPC schema is tracked automatically) and its `input_ids`
slice is wrapped as the `array("q")` the scheduler expects. `recv_requests`
releases the GIL for the drain + concat, so this never holds the GIL
across a wait — same contract as `zmq.NOBLOCK`.
"""
limit = max_recv if max_recv > 0 else self._max_per_poll
batch = self.server.recv_requests(limit)
# Bind once: each attribute access converts the rust vec to a fresh list.
headers, data, lengths = batch.headers, batch.data, batch.lengths
if not headers:
return []
ids_view = memoryview(data)
out = []
pos = 0 # byte offset into ids_buf
for header, n in zip(headers, lengths):
nbytes = n * 8
try:
obj = msgpack_decode_explained(header)
except MsgpackDecodeError as e:
# Return 400 for malformed request field (e.g. token_ids_logprob=[[0]].
logger.warning(
"rust ingress: dropping undecodable request %s: %s", e.rid, e.reason
)
if e.rid is not None:
self.server.push_error(e.rid, f"invalid request: {e.reason}")
pos += nbytes
continue
if n: # generate request: attach its int64 ids slice as array("q")
ids = array("q")
ids.frombytes(ids_view[pos : pos + nbytes])
obj.input_ids = ids
pos += nbytes
out.append(obj)
return out
def push_control_output(self, recv_req, output) -> None:
"""Push a control-request response through the egress ring to the waiting
request (routed by rid), encoded as **msgpack** (the ring's native
format).
A msgspec struct is converted to a *named map* (``structs.asdict``, since
the IPC structs are ``array_like`` and would otherwise lose field names)
so the Rust api_server can shape it per-endpoint (e.g. /server_info)
before rendering JSON to the client — keeping JSON formatting off the
scheduler's GIL.
"""
# Invariant: control requests always carry a rust-minted rid; without
# one the response is unroutable, so fail loudly rather than drop it.
assert (
recv_req.rid is not None
), f"control response without rid: {type(output).__name__}"
# No local try/except: a failed push propagates to run_scheduler_process's
# outer handler, which logs the full traceback (scheduler-fatal either way).
payload = (
msgspec.structs.asdict(output)
if isinstance(output, msgspec.Struct)
else output
)
# enc_hook stringifies non-native types (paths, enums); JSON
# rendering happens in Rust.
encoded = msgspec.msgpack.encode(payload, enc_hook=str)
self.server.push_result(recv_req.rid, encoded)
def push_generation(self, payload: BatchTokenIDOutput) -> None:
"""Egress redirect for generation output (replaces the zmq detokenizer).
Push the WHOLE batch into the Rust egress ring as one frame (-> detokenizer
shards -> client streams), mirroring the ingress ``input_ids`` split so the
bulk numeric columns never go through msgpack:
- ``header``: msgpack ``BatchHeader`` positional array — the per-request
scalar columns (``rids, finish_reasons, prompt_tokens, tok_lens``) plus
the shape metadata for the optional families (``*_lens`` element counts
for the flat logprob columns, ``*_reqlens``/``*_poslens`` for the ragged
and hidden ones).
- ``data``: the raw little-endian numeric buffer — every column is a
4-byte element (``f32`` values, ``i32`` indices), concatenated in the
order the Rust ``for_each_chunk`` reads them.
Logprobs are columnar: output families are per-step deltas, input
(prefill) families ride once on the first chunk. Ragged families (top-k,
token-ids) flatten a per-position ``list[list]`` into flat ``val``/``idx``
buffers plus a per-position ``lens`` vector (0 = null position). Hidden
states flatten to rows of floats (one row per output position).
"""
output_ids = payload.output_ids or []
prompt_tokens = payload.prompt_tokens or []
# Hot-path guard: almost no decode step wants logprobs / hidden states,
# so only then pay the per-request flatten + buffer packing below.
has_extra = bool(
payload.output_token_logprobs_val
or payload.input_token_logprobs_val
or payload.output_top_logprobs_val
or payload.input_top_logprobs_val
or payload.output_token_ids_logprobs_val
or payload.input_token_ids_logprobs_val
or payload.output_hidden_states
)
# Runs on the scheduler's CUDA-launch thread every decode step, so each
# Python-level pass over the batch costs inter-token latency: `rids` are
# the plain rid strings (hashed to a routing key on the Rust side with a
# per-process seed, off the GIL — not parsed; a rid is any string),
# `finished_reasons` already `dict | None`, and `output_ids` entries are
# always `array("i")` (never None) so `map(len)` and a bare
# `chain.from_iterable` stay in C.
rids = payload.rids
finish_reasons = payload.finished_reasons
tok_lens = list(map(len, output_ids))
flat_ids = array("i", chain.from_iterable(output_ids))
# Column order here MUST match BatchHeader (header_cols) and
# for_each_chunk's read order (data_cols); the extras contribution
# is ordered by the `extras` tuple below.
header_cols = [rids, finish_reasons, prompt_tokens, tok_lens]
data_cols = [flat_ids.tobytes()]
if has_extra:
# The `extras` tuple is the SINGLE source of the extras column
# order — it must match the Rust ``BatchHeader`` fields and
# ``for_each_chunk``'s read order.
#
# TODO(perf): the per-request flatten assumes the logprob/hidden
# columns are ragged, non-contiguous nested Python lists — which is
# only an assumption. The scheduler moves these off the GPU with
# `tensor.tolist()`, so revisit whether the upstream values are
# still contiguous tensors; if so, ship raw bytes + a shape
# descriptor and skip the flatten entirely.
batch_size = len(rids)
extras = (
FlatPairColumns(
"output_token_logprobs",
payload.output_token_logprobs_val or [],
payload.output_token_logprobs_idx or [],
),
FlatPairColumns(
"input_token_logprobs",
payload.input_token_logprobs_val or [],
payload.input_token_logprobs_idx or [],
first_none_to_nan=True,
),
RaggedPairColumns(
"output_top_logprobs",
payload.output_top_logprobs_val or [],
payload.output_top_logprobs_idx or [],
),
RaggedPairColumns(
"input_top_logprobs",
payload.input_top_logprobs_val or [],
payload.input_top_logprobs_idx or [],
),
RaggedPairColumns(
"output_token_ids_logprobs",
payload.output_token_ids_logprobs_val or [],
payload.output_token_ids_logprobs_idx or [],
),
RaggedPairColumns(
"input_token_ids_logprobs",
payload.input_token_ids_logprobs_val or [],
payload.input_token_ids_logprobs_idx or [],
),
NestedRowColumns(
"output_hidden_states", payload.output_hidden_states or []
),
)
# Every column is all-or-nothing per payload — which is also what makes
# a family's emptiness a reliable "nobody asked for this" signal.
active = []
for extra in extras:
populated = False
for name, col in extra.columns():
assert len(col) in (
0,
batch_size,
), f"extras column {name}: {len(col)} entries for a batch of {batch_size}"
populated |= len(col) > 0
if populated:
active.append(extra)
# Flatten only the families someone asked for. `has_extra` above is a
# per-FRAME guard, so one client enabling logprobs used to drag all
# seven families through the per-request loop: at B=4096 that is 28,672
# bound-method calls per decode step, materializing 12 columns of 4096
# zeros nobody reads. Measured 0.37 ms -> 7.90 ms GIL-held per step,
# i.e. 25-75% of a decode step added to the scheduler's critical path.
#
# Skipping `accept` leaves a family's buffers empty, which is exactly
# the wire form the Rust decoder already treats as absent (`per_req_ok`
# admits an empty column, `lens_i` reads 0 for every request). The
# `header_cols`/`data_cols` loops below still walk all seven, so column
# ORDER and arity are unchanged — an inactive family contributes empty
# columns in place rather than disappearing.
for extra in active:
accept = extra.accept # hoisted: this is the hottest loop here
for i in range(batch_size):
accept(i)
for extra in extras:
header_cols += extra.header_cols()
data_cols += extra.data_cols()
header = msgspec.msgpack.encode(header_cols)
# Pass the raw column list; the Rust side concatenates it into the frame
# with the GIL released.
if not self.server.push_batch(header, data_cols):
logger.warning(
"Rust egress closed; dropped batch of %d requests during shutdown",
len(rids),
)
@staticmethod
def _build_server_args(scheduler: Scheduler) -> str:
"""JSON blob of the scheduler's ``server_args`` for its embedded Rust
server (carries the already-resolved ``model_config``)."""
server_args = dict(vars(scheduler.server_args))
model_config = dict(vars(scheduler.model_config))
model_config["hf_config"] = None # HF config is not JSON-serializable
server_args["model_config"] = model_config
# Launch-time facts Python's /server_info reports from scheduler_info /
# the package — stamped here so the rust endpoint can serve them
# statically (no scheduler round-trip).
server_args["version"] = __version__
# Not a `server_args` field: `TokenizerManager` derives it, and the rust
# ingress needs the same number for its total-token check.
server_args["num_reserved_tokens"] = compute_num_reserved_tokens(
scheduler.server_args
)
server_args["max_total_num_tokens"] = scheduler.max_total_num_tokens
return msgspec.json.encode(server_args, enc_hook=str).decode("utf-8")
@staticmethod
def _partition_cores() -> Tuple[Optional[List[int]], Optional[List[int]]]:
"""Split this rank's allowed cores into ``(launch_cores, server_cores)``.
Pure computation — no affinity is changed here. Both sets are a subset
of this rank's NUMA-local cores (when affinity/NUMA bind is on), so the
partition stays NUMA-local. Returns ``(None, None)`` (server runs
unpinned, confined only by the process affinity) when the platform has
no affinity API or too few cores to split.
"""
if not hasattr(os, "sched_getaffinity"):
return None, None
try:
allowed = sorted(os.sched_getaffinity(0))
except OSError as e:
logger.warning("rust server: cannot read cpu affinity: %s", e)
return None, None
# Need enough cores to reserve launch cores and still pin the pools.
if len(allowed) < 4:
logger.info(
"rust server: only %d cores allowed; running pools unpinned",
len(allowed),
)
return None, None
# Keep a small slice for the launch loop; cap at 2 (the event loop is
# effectively serial) and never take more than a quarter of the cores.
reserve = min(2, len(allowed) // 4)
launch_cores = allowed[:reserve]
server_cores = allowed[reserve:]
logger.info(
"rust server cores=%s, scheduler launch cores=%s",
server_cores,
launch_cores,
)
return launch_cores, server_cores
+22 -1
View File
@@ -1450,7 +1450,28 @@ class Req(ReqDllmMixin):
# Check stop regex
if len(self.sampling_params.stop_regex_strs) > 0:
for stop_regex_str in self.sampling_params.stop_regex_strs:
if re.search(stop_regex_str, tail_str):
# Seatbelt, not validation: patterns are checked at ingress
# (Python's `normalize`, or the rust server's stricter
# `stop_regex_bound`). This runs per decode step on the hot
# path, so an `re.error` escaping here would take the whole
# scheduler down over one malformed request. Fail that request
# instead.
try:
matched = re.search(stop_regex_str, tail_str)
except (re.error, RecursionError) as e:
logger.warning(
"req %s: invalid stop_regex %r (%s); aborting the request",
self.rid,
stop_regex_str,
e,
)
self.finished_reason = FINISH_ABORT(
f"invalid stop_regex {stop_regex_str!r}: {e}",
HTTPStatus.BAD_REQUEST,
"BadRequestError",
)
break
if matched:
self.finished_reason = FINISHED_MATCHED_REGEX(
matched=stop_regex_str
)
+46 -5
View File
@@ -167,6 +167,7 @@ from sglang.srt.managers.prefill_delayer import (
PrefillDelayer,
PrefillDelayerSinglePassExecutor,
)
from sglang.srt.managers.rust_server import RustServer
from sglang.srt.managers.schedule_batch import (
FINISH_ABORT,
MultimodalInputs,
@@ -185,7 +186,10 @@ from sglang.srt.managers.scheduler_components.batch_result_processor import (
)
from sglang.srt.managers.scheduler_components.dp_attn import SchedulerDPAttnAdapter
from sglang.srt.managers.scheduler_components.flush_wrapper import SchedulerFlushWrapper
from sglang.srt.managers.scheduler_components.idle_sleeper import IdleSleeper
from sglang.srt.managers.scheduler_components.idle_sleeper import (
IdleSleeper,
RustServerIdleSleeper,
)
from sglang.srt.managers.scheduler_components.invariant_checker import (
SchedulerInvariantChecker,
create_scheduler_watchdog,
@@ -597,6 +601,10 @@ class Scheduler(
self.maybe_init_scripted_scheduler_hook()
# Start the embedded Rust frontend (rank 0) before the request receiver,
# which reads self.rust_ring_recv to pick its ingress transport.
self.maybe_init_rust_server()
self.init_request_receiver()
self.init_dp_attn_adapter()
@@ -672,9 +680,12 @@ class Scheduler(
)
self.load_snapshot_writer = None
self.recv_from_tokenizer = None
if not is_rank_zero:
return
self.recv_from_tokenizer = self.ipc_channels.recv_from_tokenizer
dp_rank = self.ps.dp_rank if self.ps.dp_rank is not None else 0
try:
self.load_snapshot_writer = create_load_snapshot_writer(
@@ -1748,11 +1759,16 @@ class Scheduler(
output = self._request_dispatcher(recv_req)
if output is not None:
if not isinstance(output, RpcReqOutput):
self.ipc_channels.send_to_tokenizer.send_output(output, recv_req)
else:
if self.rust_server is not None:
# Embedded Rust server: every control-request response goes
# back through the egress ring (the zmq tokenizer socket is
# not consumed); the Rust api_server shapes it per-endpoint.
self.rust_server.push_control_output(recv_req, output)
elif isinstance(output, RpcReqOutput):
if self.ipc_channels.recv_from_rpc is not None:
sock_send(self.ipc_channels.recv_from_rpc, output)
else:
self.ipc_channels.send_to_tokenizer.send_output(output, recv_req)
self.flush_wrapper.check_pending()
if self.external_corpus_manager is not None:
@@ -1808,9 +1824,33 @@ class Scheduler(
else:
self.scripted_scheduler_hook = None
def maybe_init_rust_server(self) -> None:
"""Start the embedded Rust server (rank 0) if ``SGLANG_RUST_SERVER`` is
set, and point the ingress receiver at it. All the plumbing lives in
``RustServer`` (scheduler_components/rust_scheduler.py)."""
is_rank_zero = (
self.ps.pp_rank == 0
and self.ps.attn_tp_rank == 0
and self.ps.attn_cp_rank == 0
)
if not (envs.SGLANG_RUST_SERVER.get() and is_rank_zero):
# Always define the attribute: init_output_streamer and the
# process_input_requests hook read self.rust_server unconditionally.
self.rust_server = None
return
rust_server = RustServer.launch(self)
self.rust_server = rust_server
# The rust server *is* the ingress source: SchedulerRequestReceiver
# drains its request ring (rust_server_mode) instead of a zmq socket.
self.recv_from_tokenizer = rust_server
# Park the idle loop on the request ring within the rank-0 rust-server
self.idle_sleeper = RustServerIdleSleeper(rust_server)
def init_request_receiver(self) -> None:
self.request_receiver = SchedulerRequestReceiver(
recv_from_tokenizer=self.ipc_channels.recv_from_tokenizer,
recv_from_tokenizer=self.recv_from_tokenizer,
recv_from_rpc=self.ipc_channels.recv_from_rpc,
recv_skipper=self.recv_skipper,
input_blocker=self.input_blocker,
@@ -1936,6 +1976,7 @@ class Scheduler(
spec_algorithm=self.spec_algorithm,
disaggregation_mode=self.disaggregation_mode,
enable_hicache_storage=lambda: self.enable_hicache_storage,
rust_server=self.rust_server,
)
def init_batch_result_processor(self) -> None:
@@ -1,9 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import zmq
from sglang.srt.environ import envs
from sglang.srt.observability.req_time_stats import real_time
from sglang.srt.platforms import current_platform
if TYPE_CHECKING:
from sglang.srt.managers.rust_server import RustServer
class IdleSleeper:
"""
@@ -33,3 +40,29 @@ class IdleSleeper:
):
self.last_empty_time = real_time()
current_platform.empty_cache()
class RustServerIdleSleeper:
"""Idle sleeper for the embedded Rust server.
The Rust ingress is an in-process request ring, not a zmq socket.
Instead park directly on the ring: ``wait_ingress`` blocks until
a request is pushed — the request ring wakes the parked thread
the instant a producer pushes, so there's no added latency for real
requests — or the timeout elapses.
"""
def __init__(self, rust_server: RustServer, timeout_ms: int = 1000):
self.rust_server = rust_server
self.timeout_ms = timeout_ms
self.last_empty_time = real_time()
self.empty_cache_interval = envs.SGLANG_EMPTY_CACHE_INTERVAL.get()
def maybe_sleep(self):
self.rust_server.wait_ingress(self.timeout_ms)
if (
self.empty_cache_interval > 0
and real_time() - self.last_empty_time > self.empty_cache_interval
):
self.last_empty_time = real_time()
current_platform.empty_cache()
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Callable,
List,
@@ -29,6 +30,10 @@ from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
if TYPE_CHECKING:
from sglang.srt.managers.rust_server import RustServer
logger = logging.getLogger(__name__)
@@ -45,6 +50,10 @@ class SchedulerOutputStreamer:
spec_algorithm: SpeculativeAlgorithm
disaggregation_mode: DisaggregationMode
enable_hicache_storage: Callable[[], bool]
# When SGLANG_RUST_SERVER is on, generation output is pushed to the embedded
# Rust egress ring via `rust_server.push_generation` instead of the zmq
# detokenizer. None otherwise. (Rust-specific state lives in RustServer.)
rust_server: Optional[RustServer] = None
_test_stream_output_count: int = 0
def _get_storage_backend_type(self) -> str:
@@ -147,6 +156,7 @@ class SchedulerOutputStreamer:
default_stream_interval=self.server_args.stream_interval,
default_force_stream_interval=DEFAULT_FORCE_STREAM_INTERVAL,
get_cached_tokens_details=self.get_cached_tokens_details,
rust_server_mode=self.rust_server is not None,
)
for req in reqs:
if req is skip_req:
@@ -165,7 +175,10 @@ class SchedulerOutputStreamer:
is_idle_batch=is_idle_batch,
)
if payload is not None:
self.send_to_detokenizer.send_output(payload)
if self.rust_server is not None:
self.rust_server.push_generation(payload)
else:
self.send_to_detokenizer.send_output(payload)
def _maybe_log_time_stats(self, *, req: Req) -> None:
if (
@@ -260,7 +273,6 @@ class _GenerationStreamAccumulator:
default_stream_interval: int
default_force_stream_interval: int
get_cached_tokens_details: Callable[[Req], Optional[CachedTokensDetails]]
rids: list = field(default_factory=list)
http_worker_ipcs: list = field(default_factory=list)
finished_reasons: list = field(default_factory=list)
@@ -307,6 +319,10 @@ class _GenerationStreamAccumulator:
output_token_ids_logprobs_idx: Optional[list] = None
output_token_sampling_mask: Optional[list] = None
output_token_sampling_logprobs: Optional[list] = None
# Rust server mode: the Rust detokenizer reconstructs text/ids from the raw
# 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
def __post_init__(self) -> None:
if self.return_hidden_states:
@@ -367,34 +383,40 @@ class _GenerationStreamAccumulator:
send_token_offset = req.send_token_offset
send_output_token_logprobs_offset = req.send_output_token_logprobs_offset
self.rids.append(req.rid)
self.http_worker_ipcs.append(req.http_worker_ipc)
self.finished_reasons.append(
req.finished_reason.to_json() if req.finished_reason else None
)
self.decoded_texts.append(req.decoded_text)
decode_ids, read_offset = req.init_incremental_detokenize()
self.decode_ids_list.append(decode_ids[req.send_decode_id_offset :])
# Exclude the tokens after stop condition
output_ids_ = req.output_ids_through_stop
req.send_decode_id_offset = len(decode_ids)
self.read_offsets.append(read_offset)
self.output_ids.append(output_ids_[send_token_offset:])
req.send_token_offset = len(output_ids_)
self.skip_special_tokens.append(req.sampling_params.skip_special_tokens)
self.spaces_between_special_tokens.append(
req.sampling_params.spaces_between_special_tokens
)
self.no_stop_trim.append(req.sampling_params.no_stop_trim)
self.prompt_tokens.append(len(req.origin_input_ids))
self.reasoning_tokens.append(req.reasoning_tokens)
self.completion_tokens.append(len(output_ids_))
self.cached_tokens.append(req.cached_tokens)
# Collect detailed cache breakdown if available
self.cached_tokens_details.append(self.get_cached_tokens_details(req))
if not self.rust_server_mode:
# Everything below feeds the Python DetokenizerManager /
# TokenizerManager (incremental detok, meta_info, per-request metrics)
# or gets pickled into the payload (time_stats). The Rust server
# replaces those stages and builds its own metadata from the
# ChunkEvent, so `push_generation` never reads these — skip the whole
# block. The parallel lists stay empty; the payload goes straight to
# `push_generation`, which only indexes the fields appended above.
self.http_worker_ipcs.append(req.http_worker_ipc)
self.decoded_texts.append(req.decoded_text)
decode_ids, read_offset = req.init_incremental_detokenize()
self.decode_ids_list.append(decode_ids[req.send_decode_id_offset :])
req.send_decode_id_offset = len(decode_ids)
self.read_offsets.append(read_offset)
self.skip_special_tokens.append(req.sampling_params.skip_special_tokens)
self.spaces_between_special_tokens.append(
req.sampling_params.spaces_between_special_tokens
)
self.no_stop_trim.append(req.sampling_params.no_stop_trim)
self.reasoning_tokens.append(req.reasoning_tokens)
self.completion_tokens.append(len(output_ids_))
self.cached_tokens.append(req.cached_tokens)
# Collect detailed cache breakdown if available
self.cached_tokens_details.append(self.get_cached_tokens_details(req))
# Multimodal prompt token counts. In disagg decode mode the prefill node
# already computed these and transferred them via the metadata buffer
@@ -15,6 +15,7 @@ import zmq
from torch.distributed import barrier
from sglang.srt.disaggregation.utils import prepare_abort
from sglang.srt.environ import envs
from sglang.srt.managers.io_struct import (
BatchTokenizedEmbeddingReqInput,
BatchTokenizedGenerateReqInput,
@@ -35,6 +36,7 @@ from sglang.srt.utils.nvtx_utils import scheduler_nvtx_method
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.managers.rust_server import RustServer
from sglang.srt.server_args import ServerArgs
from sglang.test.scripted_runtime.scheduler_hook import ScriptedSchedulerHook
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
@@ -44,7 +46,7 @@ if TYPE_CHECKING:
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerRequestReceiver:
recv_from_tokenizer: Union[zmq.Socket, ScriptedTokenizerRecvProxy]
recv_from_tokenizer: Union[zmq.Socket, ScriptedTokenizerRecvProxy, RustServer]
recv_from_rpc: Optional[zmq.Socket]
recv_skipper: Any
input_blocker: Any
@@ -103,6 +105,15 @@ class SchedulerRequestReceiver:
if self.ps.attn_tp_rank == 0 and self.ps.attn_cp_rank == 0:
recv_reqs = []
# Rust ringbuffer backend: drain the in-process ring fed by the
# embedded Rust TokenizerManager instead of a zmq socket. Same
# non-blocking, msgpack-decoded contract as the zmq path below.
if envs.SGLANG_RUST_SERVER.get():
recv_reqs.extend(
self.recv_from_tokenizer.drain(self.max_recv_per_poll)
)
return recv_reqs
while True:
try:
if self.recv_limit_reached(len(recv_reqs)):
@@ -94,7 +94,10 @@ from sglang.srt.managers.schedule_batch import MultimodalDataItem
from sglang.srt.managers.scheduler_input_blocker import input_blocker_guard_region
from sglang.srt.managers.tokenizer_control_mixin import TokenizerControlMixin
from sglang.srt.managers.tokenizer_manager_score_mixin import TokenizerManagerScoreMixin
from sglang.srt.managers.utils import is_health_check_generate_req
from sglang.srt.managers.utils import (
compute_num_reserved_tokens,
is_health_check_generate_req,
)
from sglang.srt.observability.cpu_monitor import start_cpu_monitor_thread
from sglang.srt.observability.metrics_collector import (
STAT_LOGGER_ROLE_TOKENIZER,
@@ -117,7 +120,6 @@ from sglang.srt.server_args import (
ServerArgs,
set_global_server_args_for_tokenizer,
)
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.srt.utils import (
configure_gc_warning,
freeze_gc,
@@ -400,18 +402,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
self.max_req_input_len = None # Will be set later in engine.py
self.enable_priority_scheduling = server_args.enable_priority_scheduling
self.default_priority_value = server_args.default_priority_value
speculative_algorithm = SpeculativeAlgorithm.from_string(
server_args.speculative_algorithm
)
if speculative_algorithm.is_eagle():
# In the current eagle implementation, we store the draft tokens in the output token slots,
# so we need to reserve the space for the draft tokens.
self.num_reserved_tokens = max(
server_args.speculative_eagle_topk * server_args.speculative_num_steps,
server_args.max_speculative_num_draft_tokens,
)
else:
self.num_reserved_tokens = 0
self.num_reserved_tokens = compute_num_reserved_tokens(server_args)
self.validate_total_tokens = True
def init_tokenizer_and_processor(self):
+75
View File
@@ -2,20 +2,25 @@ from __future__ import annotations
import dataclasses
import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, Union
import msgspec
import torch
from sglang.srt.constants import HEALTH_CHECK_RID_PREFIX
from sglang.srt.eplb.expert_distribution import ExpertDistributionMetrics
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.managers import io_struct
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.model_executor.forward_batch_info import PPProxyTensors
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.srt.state_capturer.base import TopkCaptureOutput
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import GenerationBatchResult
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.eagle_info import EagleDraftInput
@@ -318,3 +323,73 @@ class EmbeddingBatchResult:
def is_health_check_generate_req(recv_req):
rid = getattr(recv_req, "rid", None)
return rid is not None and rid.startswith(HEALTH_CHECK_RID_PREFIX)
class MsgpackDecodeError(ValueError):
"""A msgpack frame the typed decoder rejected, with the failure explained:
``rid`` (when recoverable from the raw tagged array) and a human-readable
``reason`` whose leading ``$[<n>]`` array index is resolved to the struct
field name.
"""
def __init__(self, rid: Optional[str], reason: str):
super().__init__(reason)
self.rid = rid
self.reason = reason
def msgpack_decode_explained(data: bytes) -> Any:
"""`io_struct.msgpack_decode`, but a rejected frame raises
`MsgpackDecodeError` carrying the rid (recovered via an untyped re-decode of
the tagged array) and a reason with the failing field named — for callers
that must report the failure back to a client (e.g. the rust ingress)
instead of just crashing."""
# TODO: the hook_custom_types() currently only apply for unit tests, once it
# esclate to the main code, we can provide a function to access the _all_types
try:
return io_struct.msgpack_decode(data)
except Exception as e:
msg = str(e)
try:
arr = msgspec.msgpack.decode(data)
except Exception:
arr = None
if not (isinstance(arr, (list, tuple)) and arr):
raise MsgpackDecodeError(None, msg) from e
# Tagged array_like layout is [tag, *fields]; rid is the first field of
# every BaseReq struct.
rid = str(arr[1]) if len(arr) > 1 and arr[1] is not None else None
tag_to_fields = {
cls.__struct_config__.tag: cls.__struct_fields__
for cls in io_struct._all_types
if isinstance(cls, type) and issubclass(cls, msgspec.Struct)
}
fields = tag_to_fields.get(arr[0])
if fields is not None:
# Leading ``$[<n>]`` in a msgspec ValidationError path, e.g.
# ``$[12][0]``.
m = re.search(r"\$\[(\d+)\]", msg)
if m is not None:
idx = int(m.group(1))
if 1 <= idx <= len(fields):
msg = f"{msg[:m.start()]}$.{fields[idx - 1]}{msg[m.end():]}"
raise MsgpackDecodeError(rid, msg) from e
def compute_num_reserved_tokens(server_args: ServerArgs) -> int:
"""Output token slots reserved per request, on top of its input.
The current eagle implementation stores draft tokens in the output token
slots, so the context budget has to account for them; every other algorithm
reserves nothing. Shared by `TokenizerManager` and the rust server's
`server_args` blob (`RustServer._build_server_args`), which needs the same
number to run the total-token check in Rust.
"""
algorithm = SpeculativeAlgorithm.from_string(server_args.speculative_algorithm)
if not algorithm.is_eagle():
return 0
return max(
server_args.speculative_eagle_topk * server_args.speculative_num_steps,
server_args.max_speculative_num_draft_tokens,
)