Rainj me/rust server refactor2 (#35239)

This commit is contained in:
Rain Jiang
2026-08-21 16:37:02 -07:00
committed by GitHub
parent fe8f9d7457
commit 7d7ab4b5c6
61 changed files with 2531 additions and 2193 deletions
@@ -25,6 +25,8 @@ on:
value: ${{ jobs.run.outputs.jit_kernel }}
multimodal_gen:
value: ${{ jobs.run.outputs.multimodal_gen }}
rust_workspace:
value: ${{ jobs.run.outputs.rust_workspace }}
partitions:
value: ${{ jobs.run.outputs.partitions }}
partition_model_sha:
@@ -45,6 +47,7 @@ jobs:
sgl_kernel: ${{ steps.filter.outputs.sgl_kernel }}
jit_kernel: ${{ steps.filter.outputs.jit_kernel || steps.run-mode.outputs.run_all_tests }}
multimodal_gen: ${{ steps.filter.outputs.multimodal_gen || steps.run-mode.outputs.run_all_tests }}
rust_workspace: ${{ steps.filter.outputs.rust_workspace || steps.run-mode.outputs.run_all_tests }}
partitions: ${{ steps.partitions.outputs.partitions }}
partition_model_sha: ${{ steps.partition-model-sha.outputs.sha }}
runs_on_map: ${{ steps.runner-map.outputs.runs_on_map }}
@@ -89,6 +92,8 @@ jobs:
- "scripts/ci/cuda/*"
- "scripts/ci/utils/*"
- "test/**/!(*.md)"
- "rust/**"
- "proto/sglang/runtime/v1/sglang.proto"
multimodal_gen:
- ".github/workflows/pr-test.yml"
- ".github/workflows/pr-test-multimodal-gen.yml"
@@ -113,6 +118,13 @@ jobs:
# Intentionally excludes ".github/workflows/pr-test-sgl-kernel.yml" —
# see API-side detector below for rationale.
- "python/sglang/kernels/aot/**/!(*.md|THIRDPARTYNOTICES.txt|LICENSE)"
rust_workspace:
# Gates the cargo test in test/registered/rust/; exported to the
# CPU stage as its negation, SGLANG_SKIP_RUST_TESTS.
- "rust/**"
- "proto/sglang/runtime/v1/sglang.proto"
- "test/registered/rust/**"
- ".github/workflows/_pr-test-*.yml"
- name: Determine full-parallel mode
id: parallel-mode
@@ -223,6 +235,7 @@ jobs:
echo "| sgl_kernel | ${{ steps.filter.outputs.sgl_kernel }} |"
echo "| jit_kernel | ${{ steps.filter.outputs.jit_kernel || steps.run-mode.outputs.run_all_tests }} |"
echo "| multimodal_gen | ${{ steps.filter.outputs.multimodal_gen || steps.run-mode.outputs.run_all_tests }} |"
echo "| rust_workspace | ${{ steps.filter.outputs.rust_workspace || steps.run-mode.outputs.run_all_tests }} |"
echo "| b200_runner | ${{ steps.set-runner.outputs.b200_runner }} |"
echo "| enable_retry | ${{ steps.set-retry.outputs.enable_retry }} |"
echo "| continue_on_error | ${{ steps.set-continue-on-error.outputs.continue_on_error }} |"
+4 -1
View File
@@ -13,7 +13,7 @@ on:
type: string
required: true
check_changes:
description: 'toJson(needs.check-changes.outputs). Read via fromJson(...).main_package / continue_on_error / partition_model_sha.'
description: 'toJson(needs.check-changes.outputs). Read via fromJson(...).main_package / rust_workspace / continue_on_error / partition_model_sha.'
type: string
required: true
caller_inputs:
@@ -153,6 +153,9 @@ jobs:
timeout-minutes: ${{ fromJson(inputs.run_timeout_minutes) }}
env:
CONTINUE_ON_ERROR_FLAG: ${{ fromJson(inputs.check_changes).continue_on_error == 'true' && '--continue-on-error' || '' }}
# Normalized to a literal true/false: EnvBool rejects an empty string,
# which is what a missing check-changes output would expand to.
SGLANG_SKIP_RUST_TESTS: ${{ fromJson(inputs.check_changes).rust_workspace == 'true' && 'false' || 'true' }}
run: |
cd test/
python3 run_suite.py --hw cpu --suite ${{ inputs.self_name }} --auto-partition-id ${{ matrix.partition }} --auto-partition-size ${{ fromJson(inputs.partitions)[inputs.self_name].size }} --partition-model-file /tmp/partition-model.json $CONTINUE_ON_ERROR_FLAG
-17
View File
@@ -13,16 +13,6 @@ jobs:
with:
fetch-depth: 0
- name: Detect Rust workspace changes
id: paths
uses: dorny/paths-filter@v3
with:
filters: |
rust_workspace:
- 'rust/**'
- 'proto/sglang/runtime/v1/sglang.proto'
- '.github/workflows/lint.yml'
# Fail-fast gate: docs_new/ was renamed to docs/ (#32123). git's rename
# detection silently re-adds NEW files under docs_new/ on merge without a
# conflict, which would resurrect the directory. We check the checked-out
@@ -60,13 +50,6 @@ jobs:
- name: Run pre-commit checks
run: SKIP=no-commit-to-branch pre-commit run --all-files --show-diff-on-failure
# Not in the rust-ext build job: its cache key covers the built .so files,
# and a test script is not a build input. Tests take ~1s; the timeout is
# for a cold cache, which codegens the dependency graph first.
- name: Run rust/ workspace tests
if: steps.paths.outputs.rust_workspace == 'true'
run: cd rust && timeout 900 cargo test --workspace
- name: Run lychee docs checks (offline references)
uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2
with:
+3
View File
@@ -351,6 +351,9 @@ class Envs:
# ===================================================================
SGLANG_IS_IN_CI = EnvBool(False)
SGLANG_IS_IN_CI_AMD = EnvBool(False)
# Set to true by the check-changes CI job when a PR touches nothing under
# rust/; default false so local and scheduled runs never skip the cargo tests.
SGLANG_SKIP_RUST_TESTS = EnvBool(False)
SGLANG_TEST_MAX_RETRY = EnvInt(None)
# Expand jit_kernel test grids to their full parameter ranges (nightly).
SGLANG_JIT_KERNEL_RUN_FULL_TESTS = EnvBool(False)
+116 -36
View File
@@ -4,13 +4,14 @@ 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
typed `server_args` handoff, and control-response routing — out of `scheduler.py`. The
scheduler holds an `Optional[RustServer]` and delegates to it.
"""
from __future__ import annotations
import importlib
import json
import logging
import os
from array import array
@@ -37,7 +38,7 @@ if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.managers.io_struct import BatchTokenIDOutput
from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.rust_extensions._server import Server
from sglang.srt.rust_extensions._server import MmSpec, Server, ServerArgs
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
@@ -45,8 +46,10 @@ logger = logging.getLogger(__name__)
class NativeMmSpec(msgspec.Struct, frozen=True, kw_only=True):
"""Resolved parameters of the native Rust MM pipeline for one model,
consumed by the Rust worker pool (:meth:`rust_json`) and the drain
adapter (:meth:`NativeMmHost.build_native_mm`)."""
consumed by the Rust worker pool (as the typed extension ``MmSpec``, see
:meth:`RustServer._build_mm_spec`), the ``_multimodal`` parity API
(:meth:`rust_json`) and the drain adapter
(:meth:`NativeMmHost.build_native_mm`)."""
family: str
feature_shm: bool
@@ -73,7 +76,9 @@ class NativeMmSpec(msgspec.Struct, frozen=True, kw_only=True):
return 3 * self.temporal_patch_size * self.patch_size * self.patch_size
def rust_json(self) -> str:
"""The subset `sglang_mm::registry::pipeline_from_spec` parses."""
"""The subset `sglang_mm::registry::pipeline_from_spec` parses — the
JSON form the ``_multimodal`` parity API takes; the server itself is
handed the typed ``MmSpec`` instead."""
fields = (f for f in self.__struct_fields__ if f not in self.DRAIN_ONLY)
return msgspec.json.encode({f: getattr(self, f) for f in fields}).decode()
@@ -272,7 +277,7 @@ class NativeMmHost:
@staticmethod
def build_native_mm(spec: NativeMmSpec, entry):
"""Drain-time adapter: wrap the Rust-produced buffers of one ``MmHandoff``
"""Drain-time adapter: wrap the Rust-produced buffers of one ``MmEncodeResult``
into the scheduler's ``MultimodalProcessorOutput``. Wrapping only — load,
resize, patchify, token expansion and M-RoPE all ran in Rust.
@@ -405,10 +410,10 @@ class RustServer:
)
server = Server(
cls._build_server_args(scheduler),
# None -> run unpinned; the list carries the pinning decision.
cores=server_cores,
http_addr=http_addr,
server_args_json=cls._build_server_args(scheduler),
)
# Multimodal models must have a native Rust pipeline — there is no Python
@@ -443,7 +448,7 @@ class RustServer:
f"(supported: {', '.join(supported)}; "
"images only). Unset SGLANG_RUST_SERVER to serve this model."
)
server.start_mm_workers(mm_spec.rust_json(), mm_host.mm_workers)
server.start_mm_workers(cls._build_mm_spec(mm_spec), mm_host.mm_workers)
# Narrow the scheduler thread only after the server threads are launched.
if launch_cores is not None:
@@ -466,11 +471,11 @@ class RustServer:
return cls(server, mm_spec=mm_spec)
def wait_ingress(self, timeout_ms: int) -> None:
def wait_request(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)
self.server.wait_request(timeout_ms)
def drain(self, max_recv: int) -> List[Any]:
"""Ingress: non-blocking drain of the in-process ring → list of decoded
@@ -484,8 +489,10 @@ class RustServer:
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`.
never waits: the ring drain is `try_recv` (returns the instant the ring
is dry, capped at `max_recv`) and the rest is one memcpy per header
plus one for the concatenated ids — same contract as `zmq.NOBLOCK`.
Parking for work is :meth:`wait_request`, which does release the GIL.
"""
limit = max_recv if max_recv > 0 else self._max_per_poll
batch = self.server.recv_requests(limit)
@@ -553,7 +560,7 @@ class RustServer:
# rendering happens in Rust.
encoded = msgspec.msgpack.encode(payload, enc_hook=str)
self.server.push_result(recv_req.rid, encoded)
self.server.push_control_result(recv_req.rid, encoded)
def push_generation(self, payload: BatchTokenIDOutput) -> None:
"""Egress redirect for generation output (replaces the zmq detokenizer).
@@ -698,38 +705,111 @@ class RustServer:
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):
if not self.server.push_decode_result_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``)."""
def _build_mm_spec(spec: NativeMmSpec) -> MmSpec:
"""The typed MM handoff for ``Server.start_mm_workers``: the
:class:`NativeMmSpec` fields the Rust pipeline consumes, as the Rust
extension's own ``MmSpec`` class (same required-keyword contract as
:meth:`_build_server_args`; ``family`` / ``resample`` become the
extension's ``MmFamily`` / ``MmResample`` enums)."""
from sglang.srt.rust_extensions import load_rust_extension
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
# Resolved default sampling params (generation_config.json when
# `--sampling-defaults model`, {} otherwise). The rust server consumes
# these for omitted temperature/top_p in chat conversions instead of
# hard-coding the OpenAI terminal defaults.
model_config["default_sampling_params"] = (
scheduler.model_config.get_default_sampling_params()
ext = load_rust_extension("sglang.srt.rust_extensions._server")
family = {"qwen_vl": ext.MmFamily.QwenVl}[spec.family]
resample = {"aten_u8": ext.MmResample.AtenU8, "pil": ext.MmResample.Pil}[
spec.resample
]
return ext.MmSpec(
family=family,
feature_shm=spec.feature_shm,
image_token_id=spec.image_token_id,
patch_size=spec.patch_size,
merge_size=spec.merge_size,
temporal_patch_size=spec.temporal_patch_size,
min_pixels=spec.min_pixels,
max_pixels=spec.max_pixels,
image_mean=spec.image_mean,
image_std=spec.image_std,
resample=resample,
)
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()
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 _build_server_args(scheduler: Scheduler) -> ServerArgs:
"""The typed launch handoff for the scheduler's embedded Rust server:
the ``server_args`` fields it reads, the already-resolved
``model_config``, and launch-time facts — as the Rust extension's own
``ServerArgs`` class. Its constructor takes every field as a required
keyword (see ``rust/sglang-server/src/message/config.rs``), so a
missing, extra or mistyped field fails here at boot rather than
running on a silently-defaulted knob."""
from sglang.srt.rust_extensions import load_rust_extension
ext = load_rust_extension("sglang.srt.rust_extensions._server")
sa = scheduler.server_args
mc = scheduler.model_config
disaggregation_mode = {
"null": ext.DisaggregationMode.Null,
"prefill": ext.DisaggregationMode.Prefill,
"decode": ext.DisaggregationMode.Decode,
}[sa.disaggregation_mode]
return ext.ServerArgs(
model_path=sa.model_path,
served_model_name=sa.served_model_name,
tokenizer_path=sa.tokenizer_path,
revision=sa.revision,
load_format=sa.load_format,
weight_version=sa.weight_version,
host=sa.host,
port=sa.port,
log_level=sa.log_level,
log_level_http=sa.log_level_http,
chat_template=sa.chat_template,
tool_call_parser=sa.tool_call_parser,
reasoning_parser=sa.reasoning_parser,
stream_response_default_include_usage=sa.stream_response_default_include_usage,
tokenizer_worker_num=sa.tokenizer_worker_num,
detokenizer_worker_num=sa.detokenizer_worker_num,
skip_tokenizer_init=sa.skip_tokenizer_init,
incremental_streaming_output=sa.incremental_streaming_output,
disaggregation_mode=disaggregation_mode,
model_config=ext.ModelConfig(
context_len=mc.context_len,
vocab_size=mc.vocab_size,
is_multimodal=mc.is_multimodal,
# Resolved default sampling params (generation_config.json when
# `--sampling-defaults model`, {} otherwise). The rust server
# consumes these for omitted temperature/top_p in chat
# conversions instead of hard-coding the OpenAI terminal
# defaults.
default_sampling_params=ext.DefaultSamplingParams(
**mc.get_default_sampling_params()
),
),
# `preferred_sampling_params` is deliberately absent: `launch`
# refuses to start when it is set, so the Rust server never needs it.
preferred_sampling_params=(
json.dumps(sa.preferred_sampling_params)
if sa.preferred_sampling_params is not None
else None
),
allow_auto_truncate=sa.allow_auto_truncate,
enable_return_hidden_states=sa.enable_return_hidden_states,
# Not a `server_args` field: `TokenizerManager` derives it, and the
# rust ingress needs the same number for its total-token check.
num_reserved_tokens=compute_num_reserved_tokens(),
# 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).
version=__version__,
max_total_num_tokens=scheduler.max_total_num_tokens,
)
@staticmethod
def _partition_cores(
@@ -46,7 +46,7 @@ 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
Instead park directly on the ring: ``wait_request`` 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.
@@ -59,7 +59,7 @@ class RustServerIdleSleeper:
self.empty_cache_interval = envs.SGLANG_EMPTY_CACHE_INTERVAL.get()
def maybe_sleep(self):
self.rust_server.wait_ingress(self.timeout_ms)
self.rust_server.wait_request(self.timeout_ms)
if (
self.empty_cache_interval > 0
and real_time() - self.last_empty_time > self.empty_cache_interval
+2 -5
View File
@@ -383,11 +383,8 @@ def compute_num_reserved_tokens() -> int:
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. Both stamp the number once at
launch, so it has to cover every step an adaptive-spec run may switch to:
it reads the bags for the candidate-table ceiling and the current
`topk * steps`, not the untouched startup record.
`server_args` handoff (`RustServer._build_server_args`), which needs the same
number to run the total-token check in Rust.
"""
spec = get_spec()
algorithm = SpeculativeAlgorithm.from_string(spec.speculative_algorithm)
+25 -14
View File
@@ -67,21 +67,32 @@ pub fn default_registry() -> ProcessorRegistry {
}
// --- Server (pure-Rust) request pipeline ---
/// Build a family processor from the Python-side spec JSON. `Err` on an
/// unknown family or malformed spec — the caller treats that as "no Rust
/// pipeline".
/// The resolved parameters of one family pipeline — the typed form of the
/// Python-side spec, one variant per family arm. `sglang-server` builds it
/// directly from its `MmSpec` pyclass; the JSON parity API reaches it through
/// [`pipeline_from_spec`], where the `family` key selects the variant.
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(tag = "family", rename_all = "snake_case")]
pub enum PipelineSpec {
QwenVl(crate::qwen_vl::QwenVlSpec),
}
/// Build a family processor from a typed spec. `Err` when the family
/// rejects its parameters (e.g. a zero patch size).
pub fn build_pipeline(
spec: PipelineSpec,
) -> Result<Box<dyn crate::pipeline::MmFamilyProcessor>, String> {
match spec {
PipelineSpec::QwenVl(spec) => Ok(Box::new(crate::qwen_vl::QwenVlProcessor::new(spec)?)),
}
}
/// Build a family processor from the Python-side spec JSON
/// (`{"family": ..., resolved processor params}`). `Err` on an unknown family
/// or malformed spec — the caller treats that as "no Rust pipeline".
pub fn pipeline_from_spec(
json: &str,
) -> Result<Box<dyn crate::pipeline::MmFamilyProcessor>, String> {
#[derive(serde::Deserialize)]
struct Header {
family: String,
}
let header: Header = serde_json::from_str(json).map_err(|e| format!("mm spec: {e}"))?;
match header.family.as_str() {
"qwen_vl" => Ok(Box::new(crate::qwen_vl::QwenVlProcessor::from_spec_json(
json,
)?)),
other => Err(format!("unknown mm family: {other}")),
}
let spec: PipelineSpec = serde_json::from_str(json).map_err(|e| format!("mm spec: {e}"))?;
build_pipeline(spec)
}
+1 -95
View File
@@ -3,6 +3,7 @@
//! `/generate` submits a `Request` then awaits one `Done` (unary) or relays SSE
//! frames (`data: {json}` … `[DONE]`), byte-compatible with Python
//! `http_server.generate_request`; `/server_info` reuses it for one control result.
pub mod app;
mod common;
mod disaggregation;
mod frame;
@@ -12,98 +13,3 @@ mod native_api;
mod openai;
mod prefetch;
mod submit;
use std::sync::Arc;
use axum::Router;
use crate::runtime::ServerArgs;
use crate::tokenizer_manager::ActivityCounter;
use crate::tokenizer_manager::Senders;
use disaggregation::bootstrap as pd_bootstrap;
/// Shared handler state: submission handles, immutable server configuration,
/// and the API-owned chat formatter.
#[derive(Clone)]
struct AppState {
senders: Senders,
egress_buf: usize,
server_args: Arc<ServerArgs>,
chat_formatter: Option<openai::ChatFormatter>,
/// Egress heartbeat (bumped per drained ring frame).
egress_activity: ActivityCounter,
}
pub async fn serve(
listener: std::net::TcpListener,
senders: Senders,
egress_buf: usize,
server_args: Arc<ServerArgs>,
egress_activity: ActivityCounter,
// The SAME set ingress releases from — see `Ingress::on_abort`. Constructing a
// local one here would leave the api server admitting rids that nothing ever
// releases.
shutdown: flume::Receiver<()>,
) {
let chat_formatter = openai::load_chat_support(&server_args);
let state = AppState {
senders,
egress_buf,
server_args: server_args.clone(),
chat_formatter,
egress_activity,
};
// Each endpoint module registers its own routes and merges here.
let router = Router::new()
.merge(common::routes())
.merge(native_api::routes())
.merge(openai::routes());
// TODO(auth): no API-key boundary yet. Python gates every route (except
// /health*, /metrics*, OPTIONS) via `add_api_key_middleware`; until ported,
// a configured `api_key` does NOT protect these routes.
//
// No body limit, matching the Python server.
let mut app = router
.layer(axum::extract::DefaultBodyLimit::disable())
.with_state(state);
// Prefill-only KV bootstrap registry. Merged AFTER `with_state` — its
// router carries its own Arc<Registry> state, so it cannot merge into the
// Router<AppState> above — and before `log::apply`, so bootstrap traffic
// shows in the access log.
if server_args.enable_pd_bootstrap() {
let (routes, sweeper) = pd_bootstrap::router_and_sweeper();
tokio::spawn(sweeper); // cancelled with the runtime on shutdown
app = app.merge(routes);
tracing::info!("PD KV bootstrap registry mounted on the api listener");
}
// Apply logging and access log middleware.
let app = log::apply(app, &server_args);
// The listener was already bound synchronously in `runtime::start` (so a port
// conflict fails startup); adopt it into the tokio reactor here.
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
tracing::error!(error = %e, "failed to adopt pre-bound listener");
return;
}
};
// `with_connect_info` exposes the peer address to the access-log middleware.
let serve = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
);
tokio::select! {
r = serve => {
if let Err(e) = r {
tracing::error!(error = %e, "axum serve exited");
}
}
_ = shutdown.recv_async() => {
tracing::info!("shutdown: stopping accepts, aborting in-flight handlers");
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Router assembly and the shared handler state: every endpoint module
//! registers its routes here, and [`serve`] runs the assembled app on the
//! pre-bound listener until shutdown.
use std::sync::Arc;
use axum::Router;
use super::disaggregation::bootstrap as pd_bootstrap;
use super::{common, log, native_api, openai};
use crate::message::config::ServerArgs;
use crate::tokenizer_manager::from_scheduler::ActivityCounter;
use crate::tokenizer_manager::wiring::Senders;
/// Shared handler state: submission handles, immutable server configuration,
/// and the API-owned chat formatter.
///
/// axum clones the router state into **every** request, so it is mounted as
/// `Arc<AppState>` — one refcount bump per request instead of cloning each
/// `flume::Sender` and the chat formatter. Deliberately not `Clone`, so it
/// can only be shared through that `Arc`.
pub(super) struct AppState {
pub(super) senders: Senders,
pub(super) response_buf: usize,
pub(super) server_args: Arc<ServerArgs>,
pub(super) chat_formatter: Option<openai::ChatFormatter>,
/// Response heartbeat (bumped per drained ring frame).
pub(super) response_activity: ActivityCounter,
}
pub async fn serve(
listener: std::net::TcpListener,
senders: Senders,
response_buf: usize,
server_args: Arc<ServerArgs>,
response_activity: ActivityCounter,
// 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
// aborted with the api runtime.
shutdown: flume::Receiver<()>,
) {
let chat_formatter = openai::load_chat_support(&server_args);
let state = Arc::new(AppState {
senders,
response_buf,
server_args: server_args.clone(),
chat_formatter,
response_activity,
});
// Each endpoint module registers its own routes and merges here.
let router = Router::new()
.merge(common::routes())
.merge(native_api::routes())
.merge(openai::routes());
// TODO(auth): no API-key boundary yet. Python gates every route (except
// /health*, /metrics*, OPTIONS) via `add_api_key_middleware`; until ported,
// a configured `api_key` does NOT protect these routes.
//
// No body limit, matching the Python server.
let mut app = router
.layer(axum::extract::DefaultBodyLimit::disable())
.with_state(state);
// Prefill-only KV bootstrap registry. Merged AFTER `with_state` — its
// router carries its own Arc<Registry> state, so it cannot merge into the
// Router<Arc<AppState>> above — and before `log::apply`, so bootstrap traffic
// shows in the access log.
if server_args.enable_pd_bootstrap() {
let (routes, sweeper) = pd_bootstrap::router_and_sweeper();
tokio::spawn(sweeper); // cancelled with the runtime on shutdown
app = app.merge(routes);
tracing::info!("PD KV bootstrap registry mounted on the api listener");
}
// Apply logging and access log middleware.
let app = log::apply(app, &server_args);
// The listener was already bound synchronously in `runtime::start` (so a port
// conflict fails startup); adopt it into the tokio reactor here.
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
tracing::error!(error = %e, "failed to adopt pre-bound listener");
return;
}
};
// `with_connect_info` exposes the peer address to the access-log middleware.
let serve = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
);
tokio::select! {
r = serve => {
if let Err(e) = r {
tracing::error!(error = %e, "axum serve exited");
}
}
_ = shutdown.recv_async() => {
tracing::info!("shutdown: stopping accepts, aborting in-flight handlers");
}
}
}
+29 -29
View File
@@ -12,17 +12,21 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
use std::sync::Arc;
use super::AppState;
use super::app::AppState;
use super::guard::AbortGuard;
use super::submit::submit;
use crate::message::{ControlRequest, EgressItem, GetInternalStateReq, RequestKind};
use crate::runtime::ServerArgs;
use crate::message::config::ServerArgs;
use crate::message::ids::Rid;
use crate::message::io_struct::{ControlRequest, GetInternalStateReq};
use crate::message::request::RequestKind;
use crate::message::response::ResponseItem;
/// The routes this module owns, mounted by `api_server::serve`.
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
// Control-plane: reuses the ingress FSM (no tokenization), returns one
// Control-plane: reuses the request FSM (no tokenization), returns one
// non-streamed JSON result. Adding one = a route line + its struct tag.
.route("/server_info", get(server_info))
// Static config, no scheduler round-trip. `/get_model_info` (+ `/model_info`
@@ -31,7 +35,7 @@ pub(super) fn routes() -> Router<AppState> {
.route("/model_info", get(model_info))
}
/// Submit a control request through the ingress FSM (no tokenization) and await the
/// Submit a control request through the request FSM (no tokenization) and await the
/// scheduler's single msgpack result (a `structs.asdict` named map). Returns the
/// raw bytes, or an error `Response` to return as-is.
async fn await_control_result(
@@ -50,34 +54,27 @@ async fn await_control_result(
guard.disarm(&rid); // completed normally — nothing to abort
}
match received {
Some(EgressItem::Control(bytes)) => Ok(bytes),
Some(EgressItem::Error(e)) => {
Some(ResponseItem::Control(bytes)) => Ok(bytes),
Some(ResponseItem::Error(e)) => {
let code =
StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
Err((code, e.to_string()).into_response())
}
// A control request never receives generation frames or service-call data.
Some(EgressItem::Frame(_)) | Some(EgressItem::Done(_)) | Some(EgressItem::Data(_)) => {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected generation output for control request",
)
.into_response())
}
Some(ResponseItem::Frame(_))
| Some(ResponseItem::Done(_))
| Some(ResponseItem::Data(_)) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected generation output for control request",
)
.into_response()),
None => Err((StatusCode::from_u16(499).unwrap(), "request aborted").into_response()),
}
}
/// `GET /get_model_info` (+ `/model_info` alias) — static model metadata from
/// `server_args` (no scheduler round-trip); `is_generation` always true.
///
/// Under `SGLANG_RUST_SERVER=1` this is the only `/model_info` a client can
/// reach — `launch_server` never mounts the Python app — so it answers the same
/// keys. It answers them from the launch blob, which is the whole of this
/// server's config knowledge: `server_args` is parsed once at boot and held
/// behind an `Arc`, and no route mounted here changes weights or parsers, so
/// the launch values are also the current ones.
async fn model_info(State(state): State<AppState>) -> Response {
async fn model_info(State(state): State<Arc<AppState>>) -> Response {
let sa = &state.server_args;
let body = serde_json::json!({
"model_path": sa.model_path,
@@ -111,12 +108,10 @@ async fn model_info(State(state): State<AppState>) -> Response {
/// `api_key`/`admin_api_key`; see [`shape_server_info`]).
///
/// TODO(server_info): Python also includes `kv_events`; add once plumbed.
async fn server_info(State(state): State<AppState>) -> Response {
async fn server_info(State(state): State<Arc<AppState>>) -> Response {
let bytes = match await_control_result(
&state,
ControlRequest::GetInternalStateReq(GetInternalStateReq::new(
crate::ids::Rid::new().to_string(),
)),
ControlRequest::GetInternalStateReq(GetInternalStateReq::new(Rid::new().to_string())),
)
.await
{
@@ -216,8 +211,13 @@ mod tests {
let mut msgpack = Vec::new();
rmpv::encode::write_value(&mut msgpack, &outer).unwrap();
let sa =
ServerArgs::from_json(r#"{"model_path": "/m", "api_key": "secret-token"}"#).unwrap();
// `api_key` is deliberately NOT a `ServerArgs` field — the typed schema
// cannot carry it — so the only place it could leak from is the raw
// scheduler dump shaped above.
let sa = ServerArgs {
model_path: "/m".into(),
..Default::default()
};
let out = shape_server_info(&msgpack, &sa).unwrap();
let text = String::from_utf8(out.clone()).unwrap();
// No secret leaks anywhere in the serialized response.
@@ -15,6 +15,7 @@ use axum::routing::{post, put};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use crate::utils::environ;
use crate::utils::response::json_error;
use crate::utils::serialize::{parse_int, parse_int_opt, parse_int_vec};
@@ -337,7 +338,7 @@ fn router(state: Arc<Registry>) -> Router {
/// Drop room entries
async fn cleanup_sweeper(state: Arc<Registry>) {
let cleanup_interval = Duration::from_secs(crate::environ::env_u64(
let cleanup_interval = Duration::from_secs(environ::env_u64(
ENTRY_CLEANUP_INTERVAL_ENV,
ENTRY_CLEANUP_INTERVAL_DEFAULT_SECS,
));
@@ -356,7 +357,10 @@ pub(crate) fn router_and_sweeper() -> (Router, impl std::future::Future<Output =
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{Runtime, RuntimeConfig, RustServerServerArgs, ServerArgs};
use crate::message::config::{
DisaggregationMode, RuntimeConfig, RustServerServerArgs, ServerArgs,
};
use crate::runtime::Runtime;
use std::io::{Read, Write};
use std::net::SocketAddr;
@@ -413,35 +417,36 @@ mod tests {
const SENTINEL: &str =
"/route?prefill_dp_rank=-1&prefill_cp_rank=-1&target_tp_rank=-1&target_pp_rank=-1";
/// Minimal prefill boot blob (same shape as the `runtime` tests): no
/// tokenizer load, the two mandatory `model_config` fields, and the
/// prefill role that mounts the registry.
const TEST_SERVER_ARGS: &str = r#"{
"skip_tokenizer_init": true,
"disaggregation_mode": "prefill",
"model_config": {"context_len": 2048, "vocab_size": 1000}
}"#;
/// Minimal boot config (same shape as the `runtime` tests): no tokenizer
/// load, a complete default `model_config`, and the given PD role.
fn test_server_args(disaggregation_mode: DisaggregationMode) -> ServerArgs {
ServerArgs {
skip_tokenizer_init: true,
disaggregation_mode,
..Default::default()
}
}
/// Pick a free port (probe-bind pattern, as in the `runtime` tests) and
/// boot the full runtime there with the bootstrap registry mounted — the
/// registry serves on the api listener, so these tests also pin the merge
/// wiring (including the `enable_pd_bootstrap()` derivation from the
/// blob), not just the handlers.
/// role), not just the handlers.
fn start_on_free_port() -> (Runtime, SocketAddr) {
start_runtime(TEST_SERVER_ARGS)
start_runtime(test_server_args(DisaggregationMode::Prefill))
}
fn start_runtime(server_args_json: &str) -> (Runtime, SocketAddr) {
fn start_runtime(server_args: ServerArgs) -> (Runtime, SocketAddr) {
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = probe.local_addr().unwrap();
drop(probe);
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
http_api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(ServerArgs::from_json(server_args_json).unwrap()),
server_args: Arc::new(server_args),
};
(crate::runtime::start(cfg).expect("start runtime"), addr)
}
@@ -593,11 +598,7 @@ mod tests {
/// hiding a misdirected decode/router behind its retry loop.
#[test]
fn routes_absent_off_prefill() {
let non_prefill = r#"{
"skip_tokenizer_init": true,
"model_config": {"context_len": 2048, "vocab_size": 1000}
}"#;
let (_rt, addr) = start_runtime(non_prefill);
let (_rt, addr) = start_runtime(test_server_args(DisaggregationMode::Null));
let (status, _) = request(addr, "GET", SENTINEL, None);
assert_eq!(status, 404);
+2 -2
View File
@@ -4,7 +4,7 @@
//! abort frames). No HTTP here — the sibling `native_api` module owns the handlers
//! and streams; it calls these per frame.
use crate::message::{ChunkEvent, ChunkExtras};
use crate::message::response::{ChunkEvent, ChunkExtras};
/// The text slot of a `[logprob, token_id, text]` tuple: the decoded token when
/// `return_text_in_logprobs` supplied a text buffer, else `null`.
@@ -152,7 +152,7 @@ fn hidden_states_rows(vals: &[f32], lens: &[u32]) -> serde_json::Value {
// `get`, not a clamped index: clamping only the END leaves `off` past
// `vals.len()` after one over-long row, making the next range reversed
// (`start > end`) — which panics on the api thread rather than yielding
// an empty row. Same reasoning as the egress decoder's `take_f32`.
// an empty row. Same reasoning as the decoder's `take_f32`.
rows.push(serde_json::json!(vals.get(off..off + l).unwrap_or(&[])));
off += l;
}
+8 -8
View File
@@ -5,8 +5,8 @@
use std::collections::HashSet;
use crate::ids::Rid;
use crate::tokenizer_manager::{AbortSource, Senders};
use crate::message::ids::Rid;
use crate::tokenizer_manager::wiring::{AbortSource, Senders};
/// Aborts still-in-flight rids on drop. Each rid is disarmed on natural finish;
/// whatever remains at drop is aborted.
@@ -64,7 +64,7 @@ impl Drop for AbortGuard {
// The lane is unbounded, so this send only fails at shutdown, when the loop
// is gone and nothing is generating anyway.
for rid in self.rids.drain() {
let _ = self.senders.abort.send(AbortSource::Guard(rid));
let _ = self.senders.abort_tx.send(AbortSource::Guard(rid));
}
}
}
@@ -75,10 +75,10 @@ mod tests {
fn senders_with_abort(abort: flume::Sender<AbortSource>) -> Senders {
Senders {
tm: flume::unbounded().0,
abort,
tok: flume::unbounded().0,
detok: vec![],
tok_manager_tx: flume::unbounded().0,
abort_tx: abort,
tokenizer_tx: flume::unbounded().0,
detokenizer_tx: vec![],
}
}
@@ -105,7 +105,7 @@ mod tests {
/// An armed guard aborts its rid on drop — exactly the cleanup a busy-skipped
/// `/health_generate` probe relies on. It never sees a terminal frame here, so
/// dropping the guard is the only path that deregisters its detok sink (via the
/// ingress `on_abort`). Regression for the detok-entry leak per health probe.
/// request `on_abort`). Regression for the detok-entry leak per health probe.
#[test]
fn armed_guard_aborts_on_drop() {
let (tm_tx, tm_rx) = flume::unbounded();
+1 -1
View File
@@ -6,7 +6,7 @@
use axum::{Router, response::Response};
use crate::runtime::ServerArgs;
use crate::message::config::ServerArgs;
/// Install the access-log middleware when `server_args` enables it; identity
/// otherwise (the layer is never installed, so disabled stays zero-cost).
+56 -54
View File
@@ -1,5 +1,5 @@
//! The native SGLang data-plane endpoints: `/generate` (submit a request, then
//! either fold egress frames to one unary JSON response or relay them as SSE
//! either fold decode frames to one unary JSON response or relay them as SSE
//! `data: {json}` … `[DONE]`, byte-compatible with Python
//! `http_server.generate_request`) and `/health` + `/health_generate` (which
//! round-trip a 1-token generate probe). Frame shaping (`meta_info`, logprob
@@ -7,10 +7,9 @@
//! generate-request submission (`submit`); the shared `AppState` lives in the
//! parent `api_server` module.
use std::{
convert::Infallible,
time::{Duration, Instant},
};
use std::convert::Infallible;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
Json, Router,
@@ -25,18 +24,20 @@ use axum::{
};
use tokio::sync::mpsc;
use super::AppState;
use super::app::AppState;
use super::frame::{
OutputAccumulator, cumulative_frame_string, frame_value, stream_frame_string, tag_value,
};
use super::guard::AbortGuard;
use super::submit::submit;
use crate::environ::env_bool;
use crate::ids::Rid;
use crate::message::{
ChunkEvent, EgressItem, GenerateBody, GenerateRequest, RequestKind, SamplingParams,
use crate::message::ids::Rid;
use crate::message::request::{GenerateBody, GenerateRequest, RequestKind};
use crate::message::response::{ChunkEvent, ResponseItem};
use crate::message::sampling::SamplingParams;
use crate::utils::{
environ,
response::{error_response, error_value},
};
use crate::utils::response::{error_response, error_value};
/// API-local timing for one request.
///
@@ -78,7 +79,7 @@ impl RequestTiming {
}
/// The routes this module owns, mounted by `api_server::serve`.
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/generate", post(generate))
.merge(health_routes())
@@ -97,11 +98,11 @@ pub(super) fn native_error(code: StatusCode, message: &str, stream: bool) -> Res
/// always; `SGLANG_ENABLE_HEALTH_ENDPOINT_GENERATION` (default true, mirroring
/// Python) decides whether `/health` shares it or is a plain 200 (routing the
/// request already proves the frontend is up).
fn health_routes() -> Router<AppState> {
fn health_routes() -> Router<Arc<AppState>> {
let timeout =
std::time::Duration::from_secs(crate::environ::env_u64("SGLANG_HEALTH_CHECK_TIMEOUT", 20));
let probe = get(move |state: State<AppState>| health_generate(state, timeout));
let health = if env_bool("SGLANG_ENABLE_HEALTH_ENDPOINT_GENERATION", true) {
std::time::Duration::from_secs(environ::env_u64("SGLANG_HEALTH_CHECK_TIMEOUT", 20));
let probe = get(move |state: State<Arc<AppState>>| health_generate(state, timeout));
let health = if environ::env_bool("SGLANG_ENABLE_HEALTH_ENDPOINT_GENERATION", true) {
probe.clone()
} else {
get(|| async { StatusCode::OK.into_response() })
@@ -116,19 +117,21 @@ fn health_routes() -> Router<AppState> {
const FAKE_BOOTSTRAP_HOST: &str = "2.2.2.2";
/// `GET /health_generate` — deep health: confirm the scheduler → detok path is
/// producing output. 200 iff the egress heartbeat advances within `timeout`
/// producing output. 200 if the response heartbeat advances within `timeout`
/// (from `SGLANG_HEALTH_CHECK_TIMEOUT`, frozen at router build), else 503.
/// (`/health` uses the same handler when its env gate is on.)
///
/// Fires a pre-tokenized 1-token probe (`input_ids = [0]`, skips the tokenizer) so
/// an idle pipeline produces a frame, then watches the *global*
/// [`AppState::egress_activity`] counter (not the probe's own rid) — so a busy
/// [`AppState::response_activity`] counter (not the probe's own rid) — so a busy
/// server passes immediately and a backlog never false-503s (the analogue of
/// Python's `last_receive_tstamp`). The `HEALTH_CHECK` skip + `http_worker_ipc`
/// ack are irrelevant here: this single-process server owns the egress ring.
async fn health_generate(State(state): State<AppState>, timeout: std::time::Duration) -> Response {
/// Python's `last_receive_tstamp`).
async fn health_generate(
State(state): State<Arc<AppState>>,
timeout: std::time::Duration,
) -> Response {
let baseline = state
.egress_activity
.response_activity
.load(std::sync::atomic::Ordering::Relaxed);
// Fire the probe (the heartbeat is the signal, not its own response). A busy
@@ -167,7 +170,7 @@ async fn health_generate(State(state): State<AppState>, timeout: std::time::Dura
let deadline = tokio::time::Instant::now() + timeout;
loop {
if state
.egress_activity
.response_activity
.load(std::sync::atomic::Ordering::Relaxed)
!= baseline
{
@@ -189,7 +192,7 @@ async fn health_generate(State(state): State<AppState>, timeout: std::time::Dura
/// with **400** (Python's status for a bad request) carrying serde's field-level
/// message, instead of axum's default 422.
async fn generate(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
body: Result<Json<GenerateBody>, JsonRejection>,
) -> Response {
let body = match body {
@@ -280,18 +283,18 @@ async fn generate_single(
/// Fold a unary request to its terminal → (HTTP status, result/`error` JSON, saw-terminal);
/// `false` = truncation, caller keeps the abort guard armed. Shared by single + batch.
async fn drain_unary(
rx: &mut mpsc::Receiver<EgressItem>,
rx: &mut mpsc::Receiver<ResponseItem>,
rid_str: &str,
mut timing: RequestTiming,
) -> (StatusCode, serde_json::Value, bool) {
let mut acc = OutputAccumulator::default();
while let Some(item) = rx.recv().await {
match item {
EgressItem::Frame(out) => {
ResponseItem::Frame(out) => {
timing.observe_first_output();
acc.fold(&out);
}
EgressItem::Done(out) => {
ResponseItem::Done(out) => {
timing.observe_first_output();
timing.finish();
acc.fold(&out);
@@ -310,14 +313,14 @@ async fn drain_unary(
add_e2e_latency(&mut value, &timing);
return (StatusCode::OK, value, true);
}
EgressItem::Error(e) => {
ResponseItem::Error(e) => {
timing.finish();
let code = e.http_status();
let status =
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
return (status, error_value(code, &e.to_string()), true);
}
EgressItem::Control(_) | EgressItem::Data(_) => continue, // never on `/generate`
ResponseItem::Control(_) | ResponseItem::Data(_) => continue, // never on `/generate`
}
}
// Sender dropped without a terminal item: the shard dropped this request (a
@@ -393,8 +396,8 @@ async fn generate_batch(
/// back for `FuturesUnordered` to re-poll. Empty result = channel closed.
async fn recv_indexed(
index: usize,
mut rx: mpsc::Receiver<EgressItem>,
) -> (usize, mpsc::Receiver<EgressItem>, Vec<EgressItem>) {
mut rx: mpsc::Receiver<ResponseItem>,
) -> (usize, mpsc::Receiver<ResponseItem>, Vec<ResponseItem>) {
let mut items = Vec::new();
match rx.recv().await {
Some(item) => items.push(item),
@@ -410,7 +413,7 @@ async fn recv_indexed(
/// `with_index` tags each frame (batch only), `incremental` = delta vs cumulative,
/// `guard` aborts unfinished on drop.
fn generation_event_stream(
receivers: Vec<(Rid, mpsc::Receiver<EgressItem>, RequestTiming)>,
receivers: Vec<(Rid, mpsc::Receiver<ResponseItem>, RequestTiming)>,
mut guard: AbortGuard,
incremental: bool,
with_index: bool,
@@ -456,7 +459,7 @@ fn generation_event_stream(
for item in items {
match item {
EgressItem::Frame(out) => {
ResponseItem::Frame(out) => {
timings[i].observe_first_output();
accs[i].fold(&out);
if incremental {
@@ -465,17 +468,17 @@ fn generation_event_stream(
coalesced = true;
}
}
EgressItem::Done(out) => {
ResponseItem::Done(out) => {
timings[i].observe_first_output();
timings[i].finish();
accs[i].fold(&out);
terminal = Some(out);
}
EgressItem::Error(e) => {
ResponseItem::Error(e) => {
timings[i].finish();
failed = Some(e);
}
EgressItem::Control(_) | EgressItem::Data(_) => {} // never on /generate
ResponseItem::Control(_) | ResponseItem::Data(_) => {} // never on /generate
}
}
@@ -539,29 +542,30 @@ fn terminal_stream_frame_string(
#[cfg(test)]
mod tests {
use super::*;
use crate::message::ChunkEvent;
use crate::tokenizer_manager::Senders;
use crate::message::response::ChunkEvent;
use crate::tokenizer_manager::wiring::Senders;
use crate::utils::error::Error;
use futures::StreamExt;
use std::time::Duration;
fn senders() -> Senders {
Senders {
tm: flume::unbounded().0,
abort: flume::unbounded().0,
tok: flume::unbounded().0,
detok: vec![],
tok_manager_tx: flume::unbounded().0,
abort_tx: flume::unbounded().0,
tokenizer_tx: flume::unbounded().0,
detokenizer_tx: vec![],
}
}
fn frame(rid: u64, text: &str) -> EgressItem {
EgressItem::Frame(ChunkEvent {
fn frame(rid: u64, text: &str) -> ResponseItem {
ResponseItem::Frame(ChunkEvent {
rid: Rid::from(rid.to_string()),
text: text.into(),
completion_tokens: 1,
..Default::default()
})
}
fn done(rid: u64, text: &str) -> EgressItem {
EgressItem::Done(ChunkEvent {
fn done(rid: u64, text: &str) -> ResponseItem {
ResponseItem::Done(ChunkEvent {
rid: Rid::from(rid.to_string()),
text: text.into(),
completion_tokens: 1,
@@ -579,8 +583,8 @@ mod tests {
fn timed_receiver(
rid: u64,
rx: mpsc::Receiver<EgressItem>,
) -> (Rid, mpsc::Receiver<EgressItem>, RequestTiming) {
rx: mpsc::Receiver<ResponseItem>,
) -> (Rid, mpsc::Receiver<ResponseItem>, RequestTiming) {
(
Rid::from(rid.to_string()),
rx,
@@ -630,7 +634,7 @@ mod tests {
#[tokio::test]
async fn unary_terminal_meta_info_matches_python_semantics() {
let (tx, mut rx) = mpsc::channel(2);
tx.send(EgressItem::Done(ChunkEvent {
tx.send(ResponseItem::Done(ChunkEvent {
rid: "internal-rid".into(),
text: "ok".into(),
token_ids: vec![7, 8],
@@ -723,11 +727,9 @@ mod tests {
generation_event_stream(receivers, AbortGuard::new_empty(senders()), false, true);
futures::pin_mut!(stream);
tx0.send(EgressItem::Error(crate::error::Error::Validation(
"bad".into(),
)))
.await
.unwrap();
tx0.send(ResponseItem::Error(Error::Validation("bad".into())))
.await
.unwrap();
let v = parse(&stream.next().await.unwrap());
assert_eq!(v["index"], 0);
assert_eq!(v["error"]["code"], 400);
+19 -16
View File
@@ -6,6 +6,7 @@
use axum::{Router, http::StatusCode, response::Response};
use futures::StreamExt;
use std::sync::Arc;
use tokio::sync::mpsc;
mod chat;
@@ -17,19 +18,21 @@ mod tools;
pub(super) use template::ChatFormatter;
use super::AppState;
use super::app::AppState;
use super::frame::OutputAccumulator;
use super::guard::AbortGuard;
use super::submit::submit;
use crate::ids::Rid;
use crate::message::{ChunkEvent, EgressItem, GenerateRequest, RequestKind};
use crate::runtime::ServerArgs;
use crate::message::config::ServerArgs;
use crate::message::ids::Rid;
use crate::message::request::{GenerateRequest, RequestKind};
use crate::message::response::{ChunkEvent, ResponseItem};
use crate::tokenizer_manager::tokenizer;
use crate::utils::response::error_response;
const MAX_OPENAI_CHOICES: usize = 4096;
/// The routes this module owns, mounted by `api_server::serve`.
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.merge(models::routes())
.merge(completions::routes())
@@ -47,7 +50,7 @@ pub(super) fn load_chat_support(server_args: &ServerArgs) -> Option<ChatFormatte
if server_args.skip_tokenizer_init || server_args.tokenizer_path.is_empty() {
return None;
}
let config_file = crate::tokenizer::resolve_model_file(
let config_file = tokenizer::resolve_model_file(
&server_args.tokenizer_path,
server_args.revision.as_deref(),
"tokenizer_config.json",
@@ -114,25 +117,25 @@ pub(super) fn openai_error(code: StatusCode, message: impl Into<String>, stream:
/// `guard` on a natural terminal, and map errors / validation aborts /
/// truncation to `(status, message)` for the OpenAI error shape.
async fn collect_output(
mut rx: mpsc::Receiver<EgressItem>,
mut rx: mpsc::Receiver<ResponseItem>,
guard: &mut AbortGuard,
rid: &Rid,
) -> Result<ChunkEvent, (StatusCode, String)> {
let mut accumulator = OutputAccumulator::default();
let output = loop {
match rx.recv().await {
Some(EgressItem::Frame(output)) => accumulator.fold(&output),
Some(EgressItem::Done(output)) => {
Some(ResponseItem::Frame(output)) => accumulator.fold(&output),
Some(ResponseItem::Done(output)) => {
accumulator.fold(&output);
break accumulator.into_output();
}
Some(EgressItem::Error(error)) => {
Some(ResponseItem::Error(error)) => {
guard.disarm(rid);
let status = StatusCode::from_u16(error.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
return Err((status, error.to_string()));
}
Some(EgressItem::Control(_)) | Some(EgressItem::Data(_)) => {}
Some(ResponseItem::Control(_)) | Some(ResponseItem::Data(_)) => {}
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
@@ -160,7 +163,7 @@ async fn submit_generation(
request: GenerateRequest,
stream: bool,
guard: &mut AbortGuard,
) -> Result<mpsc::Receiver<EgressItem>, Response> {
) -> Result<mpsc::Receiver<ResponseItem>, Response> {
match submit(state, RequestKind::Generate(Box::new(request)), stream).await {
Ok((rid, rx)) => {
guard.arm(rid);
@@ -177,17 +180,17 @@ async fn submit_generation(
}
}
fn indexed_egress_stream(
fn indexed_decode_stream(
index: usize,
rx: mpsc::Receiver<EgressItem>,
) -> futures::stream::BoxStream<'static, (usize, Option<EgressItem>)> {
rx: mpsc::Receiver<ResponseItem>,
) -> futures::stream::BoxStream<'static, (usize, Option<ResponseItem>)> {
futures::stream::unfold((rx, false), move |(mut rx, finished)| async move {
if finished {
return None;
}
match rx.recv().await {
Some(item) => {
let finished = matches!(item, EgressItem::Done(_) | EgressItem::Error(_));
let finished = matches!(item, ResponseItem::Done(_) | ResponseItem::Error(_));
Some(((index, Some(item)), (rx, finished)))
}
None => Some(((index, None), (rx, true))),
@@ -2,6 +2,7 @@
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::sync::Arc;
use axum::{
Json, Router,
@@ -33,18 +34,22 @@ use super::tools::{
parse_chat_tool_calls,
};
use super::{
AppState, ChatFormatter, collect_output, contains_media, error_payload, indexed_egress_stream,
AppState, ChatFormatter, collect_output, contains_media, error_payload, indexed_decode_stream,
openai_error, submit_generation, unix_seconds_u32,
};
use crate::ids::Rid;
use crate::message::{ChunkExtras, EgressItem, GenerateRequest, OneOrMany, SamplingParams};
use crate::message::config::{DefaultSamplingParams, ServerArgs};
use crate::message::ids::Rid;
use crate::message::request::GenerateRequest;
use crate::message::response::{ChunkExtras, ResponseItem};
use crate::message::sampling::SamplingParams;
use crate::message::types::OneOrMany;
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route("/v1/chat/completions", post(chat_completions))
}
async fn chat_completions(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
body: Result<Json<CreateChatCompletionRequest>, JsonRejection>,
) -> Response {
let request = match body {
@@ -283,7 +288,7 @@ pub(super) fn chat_sampling(
tool_choice: &DynamoToolChoice,
tools: &[ToolDefinition],
parallel_tool_calls: Option<bool>,
server_args: &crate::runtime::ServerArgs,
server_args: &ServerArgs,
) -> Result<SamplingParams, String> {
let mut sampling = chat_sampling_params(
request,
@@ -299,7 +304,7 @@ pub(super) fn chat_sampling(
sampling
.normalize(
server_args.skip_tokenizer_init,
server_args.model_config.vocab_size.unwrap_or(u64::MAX),
server_args.model_config.vocab_size,
)
.map_err(|error| error.to_string())?;
Ok(sampling)
@@ -352,10 +357,7 @@ impl SamplingDefaults {
};
/// The resolved model defaults (empty in `--sampling-defaults openai`
/// mode), which slot between the user's values and the OpenAI terminals.
pub(super) fn with_model_defaults(
mut self,
model: &crate::runtime::DefaultSamplingParams,
) -> SamplingDefaults {
pub(super) fn with_model_defaults(mut self, model: &DefaultSamplingParams) -> SamplingDefaults {
self.temperature = model.temperature;
self.top_p = model.top_p;
self
@@ -421,7 +423,7 @@ pub(super) fn chat_sampling_params(
#[allow(clippy::too_many_arguments)]
pub(super) async fn unary_chat(
submitted: Vec<(usize, Rid, mpsc::Receiver<EgressItem>)>,
submitted: Vec<(usize, Rid, mpsc::Receiver<ResponseItem>)>,
mut guard: AbortGuard,
response_id: String,
model: String,
@@ -505,7 +507,7 @@ pub(super) async fn unary_chat(
#[allow(clippy::too_many_arguments)]
pub(super) fn chat_event_stream(
submitted: Vec<(usize, Rid, mpsc::Receiver<EgressItem>)>,
submitted: Vec<(usize, Rid, mpsc::Receiver<ResponseItem>)>,
mut guard: AbortGuard,
response_id: String,
model: String,
@@ -541,7 +543,7 @@ pub(super) fn chat_event_stream(
for (index, rid, rx) in submitted {
rids.push(rid);
streams.push(indexed_egress_stream(index, rx));
streams.push(indexed_decode_stream(index, rx));
yield Annotated {
data: Some(CreateChatCompletionStreamResponse {
id: response_id.clone(),
@@ -578,12 +580,12 @@ pub(super) fn chat_event_stream(
continue;
};
let output = match item {
EgressItem::Frame(output) => output,
EgressItem::Done(output) => {
ResponseItem::Frame(output) => output,
ResponseItem::Done(output) => {
guard.disarm(&rids[index]);
output
}
EgressItem::Error(error) => {
ResponseItem::Error(error) => {
guard.disarm(&rids[index]);
yield Annotated {
data: None,
@@ -594,7 +596,7 @@ pub(super) fn chat_event_stream(
};
continue;
}
EgressItem::Control(_) | EgressItem::Data(_) => continue,
ResponseItem::Control(_) | ResponseItem::Data(_) => continue,
};
if let Some((code, message)) = output
.finish_reason
@@ -854,8 +856,8 @@ mod tests {
merge_template_stops, unary_chat,
};
use crate::api_server::guard::AbortGuard;
use crate::message::ChunkExtras;
use crate::runtime::DefaultSamplingParams;
use crate::message::config::DefaultSamplingParams;
use crate::message::response::ChunkExtras;
use axum::http::StatusCode;
use dynamo_protocols::types::{CreateChatCompletionRequest, Stop};
use futures::StreamExt;
@@ -923,7 +925,7 @@ mod tests {
));
assert_eq!(
formatter.stop_strs(),
Some(crate::message::OneOrMany::Many(vec![
Some(crate::message::types::OneOrMany::Many(vec![
"<|endoftext|>".into(),
"<|im_end|>".into()
]))
@@ -2,6 +2,7 @@
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::sync::Arc;
use axum::{
Json, Router,
@@ -23,16 +24,18 @@ use tokio::sync::mpsc;
use super::super::guard::AbortGuard;
use super::super::submit::submit;
use super::{
AppState, MAX_OPENAI_CHOICES, collect_output, error_payload, indexed_egress_stream,
AppState, MAX_OPENAI_CHOICES, collect_output, error_payload, indexed_decode_stream,
openai_error, submit_generation, unix_seconds_u32,
};
use crate::ids::Rid;
use crate::message::{
ChunkEvent, ChunkExtras, EgressItem, GenerateRequest, Matched, OneOrMany, RequestKind,
SamplingParams, TokenIds,
};
use crate::message::finish_reason::Matched;
use crate::message::ids::Rid;
use crate::message::request::{GenerateRequest, RequestKind};
use crate::message::response::{ChunkEvent, ChunkExtras, ResponseItem};
use crate::message::sampling::SamplingParams;
use crate::message::types::{OneOrMany, TokenIds};
use crate::utils::error::Error;
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route("/v1/completions", post(completions))
}
@@ -47,7 +50,7 @@ pub(super) struct SubmittedChoice {
pub(super) prompt_index: usize,
pub(super) rid: Rid,
pub(super) echo: String,
pub(super) rx: mpsc::Receiver<EgressItem>,
pub(super) rx: mpsc::Receiver<ResponseItem>,
}
#[derive(Debug, Default)]
pub(super) struct ChoiceExtensions {
@@ -58,7 +61,7 @@ pub(super) struct ChoiceExtensions {
}
async fn completions(
State(state): State<AppState>,
State(state): State<Arc<AppState>>,
body: Result<Json<CreateCompletionRequest>, JsonRejection>,
) -> Response {
let request = match body {
@@ -123,11 +126,7 @@ async fn completions(
};
if let Err(error) = sampling.normalize(
state.server_args.skip_tokenizer_init,
state
.server_args
.model_config
.vocab_size
.unwrap_or(u64::MAX),
state.server_args.model_config.vocab_size,
) {
return openai_error(StatusCode::BAD_REQUEST, error.to_string(), false);
}
@@ -252,17 +251,17 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
));
};
match rx.recv().await {
Some(EgressItem::Data(payload)) => String::from_utf8(payload.to_vec()).map_err(|_| {
Some(ResponseItem::Data(payload)) => String::from_utf8(payload.to_vec()).map_err(|_| {
openai_error(
StatusCode::INTERNAL_SERVER_ERROR,
"detokenized prompt is not valid UTF-8",
false,
)
}),
Some(EgressItem::Error(crate::error::Error::Validation(message))) => {
Some(ResponseItem::Error(Error::Validation(message))) => {
Err(openai_error(StatusCode::BAD_REQUEST, &message, false))
}
Some(EgressItem::Error(error)) => {
Some(ResponseItem::Error(error)) => {
let status = StatusCode::from_u16(error.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
Err(openai_error(
@@ -538,7 +537,7 @@ pub(super) fn completion_event_stream(
rids.push(choice.rid);
prompt_indexes.push(choice.prompt_index);
echoes.push(choice.echo);
streams.push(indexed_egress_stream(index, choice.rx));
streams.push(indexed_decode_stream(index, choice.rx));
}
let mut events = futures::stream::select_all(streams);
@@ -548,17 +547,17 @@ pub(super) fn completion_event_stream(
continue;
};
let output = match item {
EgressItem::Frame(output) => output,
EgressItem::Done(output) => {
ResponseItem::Frame(output) => output,
ResponseItem::Done(output) => {
guard.disarm(&rids[index]);
output
}
EgressItem::Error(error) => {
ResponseItem::Error(error) => {
guard.disarm(&rids[index]);
yield error_payload(StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), error.to_string()).to_string();
continue;
}
EgressItem::Control(_) | EgressItem::Data(_) => continue,
ResponseItem::Control(_) | ResponseItem::Data(_) => continue,
};
if let Some((code, message)) = output
@@ -739,7 +738,7 @@ mod tests {
completion_prompt_specs, completion_response_value, unary_completion,
};
use crate::api_server::guard::AbortGuard;
use crate::message::ChunkExtras;
use crate::message::response::ChunkExtras;
use axum::http::StatusCode;
use dynamo_protocols::types::{
Choice, CreateCompletionRequest, CreateCompletionResponse, Prompt,
@@ -7,10 +7,11 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
use std::sync::Arc;
use super::{AppState, openai_error, unix_seconds_u32};
pub(super) fn routes() -> Router<AppState> {
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/v1/models", get(available_models))
.route("/v1/models/{model}", get(retrieve_model))
@@ -18,12 +19,12 @@ pub(super) fn routes() -> Router<AppState> {
/// `GET /v1/models` — OpenAI-compatible model list. Served from `server_args`;
/// no scheduler round-trip.
async fn available_models(State(state): State<AppState>) -> Response {
async fn available_models(State(state): State<Arc<AppState>>) -> Response {
let base = model_card(&state);
Json(serde_json::json!({ "object": "list", "data": [base] })).into_response()
}
async fn retrieve_model(State(state): State<AppState>, Path(model): Path<String>) -> Response {
async fn retrieve_model(State(state): State<Arc<AppState>>, Path(model): Path<String>) -> Response {
if model != state.server_args.served_model_name {
return openai_error(
StatusCode::NOT_FOUND,
@@ -18,7 +18,7 @@ use dynamo_renderer::{ChatTemplate, ContextMixins, PromptContextMixin, PromptFor
use serde_json::Value;
use thiserror::Error;
use crate::message::OneOrMany;
use crate::message::types::OneOrMany;
const SUPPORTED_STYLES: &[&str] = &[
"ADD_COLON_SINGLE",
@@ -19,21 +19,21 @@ use serde_json::json;
use tower::util::ServiceExt;
use super::{openai_error, routes};
use crate::ids::Rid;
use crate::message::{ChunkEvent, EgressItem};
use crate::runtime::ServerArgs;
use crate::tokenizer_manager::Senders;
use crate::message::config::ServerArgs;
use crate::message::ids::Rid;
use crate::message::response::{ChunkEvent, ResponseItem};
use crate::tokenizer_manager::wiring::Senders;
pub(super) fn senders() -> Senders {
Senders {
tm: flume::unbounded().0,
abort: flume::unbounded().0,
tok: flume::unbounded().0,
detok: vec![],
tok_manager_tx: flume::unbounded().0,
abort_tx: flume::unbounded().0,
tokenizer_tx: flume::unbounded().0,
detokenizer_tx: vec![],
}
}
pub(super) fn chunk(rid: &str, text: &str, done: bool) -> EgressItem {
pub(super) fn chunk(rid: &str, text: &str, done: bool) -> ResponseItem {
let output = ChunkEvent {
rid: rid.into(),
text: text.into(),
@@ -50,20 +50,20 @@ pub(super) fn chunk(rid: &str, text: &str, done: bool) -> EgressItem {
..Default::default()
};
if done {
EgressItem::Done(output)
ResponseItem::Done(output)
} else {
EgressItem::Frame(output)
ResponseItem::Frame(output)
}
}
/// A submitted legacy completion choice with its egress channel.
/// A submitted legacy completion choice.
pub(super) fn submitted(
index: usize,
prompt_index: usize,
rid: &str,
) -> (
super::completions::SubmittedChoice,
tokio::sync::mpsc::Sender<EgressItem>,
tokio::sync::mpsc::Sender<ResponseItem>,
) {
let (tx, rx) = tokio::sync::mpsc::channel(8);
(
@@ -78,41 +78,33 @@ pub(super) fn submitted(
)
}
/// A submitted chat choice (the tuple `chat_event_stream` consumes) with its
/// egress channel.
/// A submitted chat choice (the tuple `chat_event_stream` consumes).
pub(super) fn chat_submitted(
index: usize,
rid: &str,
) -> (
(usize, Rid, tokio::sync::mpsc::Receiver<EgressItem>),
tokio::sync::mpsc::Sender<EgressItem>,
(usize, Rid, tokio::sync::mpsc::Receiver<ResponseItem>),
tokio::sync::mpsc::Sender<ResponseItem>,
) {
let (tx, rx) = tokio::sync::mpsc::channel(8);
((index, rid.into(), rx), tx)
}
// ---------------------------------------------------------------------
// Handler-level tests: full router, real extractors, no scheduler. A
// request that reaches `submit` with an OPEN tm lane would wait on the
// egress receiver forever, so submission-reaching cases use `senders_closed`
// (503) and everything else fails validation before submit.
// ---------------------------------------------------------------------
pub(super) fn server_args() -> Arc<ServerArgs> {
Arc::new(
serde_json::from_value(serde_json::json!({ "served_model_name": "model" }))
.expect("ServerArgs must deserialize"),
)
Arc::new(ServerArgs {
served_model_name: "model".into(),
..Default::default()
})
}
pub(super) fn app_state(senders: Senders) -> super::AppState {
super::AppState {
pub(super) fn app_state(senders: Senders) -> Arc<super::AppState> {
Arc::new(super::AppState {
senders,
egress_buf: 8,
response_buf: 8,
server_args: server_args(),
chat_formatter: None,
egress_activity: Default::default(),
}
response_activity: Default::default(),
})
}
pub(super) fn senders_closed() -> Senders {
@@ -126,10 +118,10 @@ pub(super) fn senders_closed() -> Senders {
let (tok_tx, tok_rx) = flume::unbounded();
drop(tok_rx);
Senders {
tm: tm_tx,
abort: abort_tx,
tok: tok_tx,
detok: vec![],
tok_manager_tx: tm_tx,
abort_tx,
tokenizer_tx: tok_tx,
detokenizer_tx: vec![],
}
}
@@ -36,7 +36,8 @@ use dynamo_protocols::types::{
Role,
};
use crate::message::{ChunkEvent, SamplingParams};
use crate::message::response::ChunkEvent;
use crate::message::sampling::SamplingParams;
/// Canonicalize a tool-call parser name onto the dynamo-parsers registry keys.
///
@@ -263,7 +264,8 @@ mod tests {
apply_tool_constraint, chat_delta, chat_finish_reason, dynamo_parser_name,
dynamo_tool_choice, parse_chat_tool_calls,
};
use crate::message::{ChunkEvent, SamplingParams};
use crate::message::response::ChunkEvent;
use crate::message::sampling::SamplingParams;
use dynamo_parsers::tool_calling::jail::{Annotated, apply_tool_calling_jail};
use dynamo_parsers::{ToolChoice as DynamoToolChoice, ToolDefinition};
use dynamo_protocols::types::CreateChatCompletionStreamResponse as StreamResponse;
@@ -5,8 +5,8 @@
//! images must download concurrently, not in `n * REQUEST_TIMEOUT`. URLs and
//! file paths resolve here through `sglang-mm`'s `fetch_bytes_budgeted` (one
//! owner for proxy/timeout/cap semantics) and ride out-of-band as
//! [`crate::message::MmData::prefetched`], which
//! [`crate::message::mm_payload::to_mm_input`] swaps back in.
//! [`crate::message::request::MmData::prefetched`], which
//! [`crate::multi_modality::payload::to_mm_input`] swaps back in.
use std::sync::Arc;
@@ -15,8 +15,8 @@ use sglang_mm::common::fetch::{ByteBudget, fetch_bytes_budgeted};
use sglang_mm::driver::{MAX_ITEMS_PER_REQUEST, MAX_REQUEST_BYTES};
use tokio::sync::Semaphore;
use crate::message::mm_payload::{io_sources, item_count};
use crate::message::{GenerateRequest, MmData};
use crate::message::request::{GenerateRequest, MmData};
use crate::multi_modality::payload::{io_sources, item_count};
/// Global bound on concurrent media fetches across all in-flight requests;
/// excess acquisitions queue on the semaphore without holding a thread.
+20 -13
View File
@@ -1,18 +1,20 @@
//! Request submission into the ingress pipeline, shared by every endpoint
//! Request submission into the to_scheduler pipeline, shared by every endpoint
//! module: mint the client-visible rid (uuid hex, Python-parity), build the
//! `Request`, and hand it to the TM with an egress receiver for the response.
//! `Request`, and hand it to the TM with a receiver for the response.
use axum::{http::StatusCode, response::Response};
use tokio::sync::mpsc;
use super::{AppState, native_api::native_error};
use crate::fsm::RequestState;
use crate::ids::Rid;
use crate::message::{EgressItem, EgressSink, Request, RequestKind};
use crate::tokenizer_manager::TmEvent;
use super::app::AppState;
use super::native_api::native_error;
use crate::message::ids::Rid;
use crate::message::request::{Request, RequestKind};
use crate::message::response::{ResponseItem, ResponseSink};
use crate::tokenizer_manager::wiring::TmEvent;
use crate::utils::fsm::RequestState;
/// Submit one request; returns the rid, its hashed routing key, and the egress
/// receiver. Every request arrives with its final rid — a generate request from
/// Submit one request; returns its rid and the response receiver. Every
/// request arrives with its final rid — a generate request from
/// `into_requests` (or the `HEALTH_CHECK_<uuid>` the health probe sets), a
/// control request from its constructor — so this only echoes it back.
pub(super) async fn submit(
@@ -21,7 +23,7 @@ pub(super) async fn submit(
// `stream`: the client is reading an SSE stream, so it expects 200 plus an
// error frame rather than a 4xx — `utils::response::error_response`'s rule.
stream: bool,
) -> Result<(Rid, mpsc::Receiver<EgressItem>), Response> {
) -> Result<(Rid, mpsc::Receiver<ResponseItem>), Response> {
let rid = match &kind {
// Generate rids are already final: `GenerateBody::into_requests` normalized the
// client's, or minted one. Control requests have no client-facing rid.
@@ -37,14 +39,19 @@ pub(super) async fn submit(
// sent for `meta_info.id`.
// Async-aware send so a full TM inbox yields (backpressure) instead of parking
// a thread; Err only when the inbox is closed (shutdown).
let (tx, rx) = mpsc::channel::<EgressItem>(state.egress_buf);
let (tx, rx) = mpsc::channel::<ResponseItem>(state.response_buf);
let request = Request {
rid: rid.clone(),
state: RequestState::Received,
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
kind,
};
match state.senders.tm.send_async(TmEvent::Ingress(request)).await {
match state
.senders
.tok_manager_tx
.send_async(TmEvent::Intake(request))
.await
{
Ok(()) => Ok((rid, rx)),
// `SendError` has a single meaning — the channel is disconnected.
Err(_) => {
+130 -156
View File
@@ -1,26 +1,19 @@
//! sglang-server: a multi-threaded Rust frontend (API server → TokenizerManager
//! sglang-server: a multi-threaded Rust frontend (HTTP server → TokenizerManager
//! → Tokenizer/Detokenizer) embedded in the Python scheduler process.
//!
//! Pipeline stages 15 are pure Rust and never touch a `PyObject`, so they run
//! concurrently with the Python scheduler without contending for the GIL. The
//! only GIL crossings are the boundary methods on [`Server`]:
//! * `recv_requests` — Python scheduler thread drains the ingress ring.
//! * `push_batch` — Python scheduler thread pushes one output batch.
//! * `push_result` — Python scheduler thread pushes one control result.
//!
//! All are non-blocking, so the GIL is never held across a wait.
//! This file is the Python↔Rust boundary: it registers the pyo3 module
//! (`_server`) and the classes exposed to the scheduler — the boot config
//! ([`ServerArgs`] and its parts, constructed by keyword from Python; their
//! `#[pyclass]`es and constructors live in `message::config`), [`Server`]
//! (boot, `recv_requests`/`wait_request`, `push_*`, MM handoff, shutdown),
//! [`RequestBatch`] and [`MmEncodeResult`]. Everything behind that boundary —
//! receiving requests, encoding multimodal inputs, tokenizing, detokenizing,
//! SSE streaming, and so on — is implemented purely in Rust and never touches
//! a `PyObject`.
mod api_server;
mod detokenizer;
mod environ;
mod error;
mod fsm;
mod ids;
mod message;
mod mm;
mod ring;
mod runtime;
mod tokenizer;
mod multi_modality;
mod tokenizer_manager;
mod utils;
@@ -30,27 +23,49 @@ use pyo3::prelude::*;
use pyo3::pybacked::PyBackedBytes;
use pyo3::types::PyBytes;
use crate::runtime::{Runtime, RuntimeConfig};
use crate::message::config::{
DefaultSamplingParams, DisaggregationMode, MmFamily, MmResample, MmSpec, ModelConfig,
RuntimeConfig, RustServerServerArgs, ServerArgs,
};
use crate::utils::{logging, runtime};
/// One drained MM result (see [`Server::take_mm`]). Exactly one of
/// `features`/`shm_names` is `Some`: inline features for single-rank serving
/// (zero-copy into numpy), or one POSIX segment name per item when the scheduler
/// broadcasts across TP ranks and Python wraps each in a `ShmPointerMMData`.
/// A `ValueError` for a boot-time failure, as `"{context}: {err}"`.
fn value_error(context: &str, err: impl std::fmt::Display) -> PyErr {
pyo3::exceptions::PyValueError::new_err(format!("{context}: {err}"))
}
/// One drained MM result (see [`Server::take_mm`]), consumed by
/// `RustServer.build_native_mm` to build the scheduler's
/// `MultimodalProcessorOutput`.
#[pyclass(frozen, get_all)]
struct MmHandoff {
struct MmEncodeResult {
/// *Generic.* All items' `pixel_values` concatenated, flat `f32` of logical
/// shape `[sum(t*h*w), feature_dim]`; `Some` on the inline (single-rank) path.
features: Option<Py<numpy::PyArray1<f32>>>,
/// *Generic.* Per-item POSIX shm segment name holding that item's features
/// (`[t*h*w, feature_dim]` f32); `Some` on the TP-broadcast path.
shm_names: Option<Vec<String>>,
grids: Vec<(u32, u32, u32)>,
/// *Generic.* Per-item content hash of the raw source bytes (or the caller's
/// `mm_hashes` override), precomputed so the drain never re-hashes.
hashes: Vec<u64>,
/// *Generic.* Per-item inclusive `(start, end)` placeholder-token span in the
/// expanded `input_ids`.
offsets: Vec<(u32, u32)>,
/// *Qwen-VL specific.* Per-item `image_grid_thw` `(t, h, w)` in patch units;
/// `t*h*w` is also the item's row count in `features`.
grids: Vec<(u32, u32, u32)>,
/// *Qwen-VL specific.* M-RoPE position ids, flat `i64` of row-major shape
/// `[3, seq_len]` (temporal, height, width rows).
mrope: Py<numpy::PyArray1<i64>>,
/// *Qwen-VL specific.* M-RoPE delta, `max(mrope) + 1 - seq_len`, that decode
/// adds to the plain sequence position.
mrope_delta: i64,
}
/// Columnar ingress batch handed to Python by [`Server::recv_requests`].
/// Columnar request batch handed to Python by [`Server::recv_requests`].
/// `frozen`: immutable snapshot, so field access never contends on a borrow.
#[pyclass(frozen, get_all)]
struct IngressBatch {
struct RequestBatch {
/// One msgpack scalar header per request (`input_ids` omitted).
headers: Vec<Py<PyBytes>>,
/// The raw-data plane today just all requests' raw little-endian int64
@@ -64,121 +79,92 @@ struct IngressBatch {
/// [`Server::start`], then poll it from the scheduler event loop.
#[pyclass]
struct Server {
rt: Runtime,
rt: runtime::Runtime,
}
#[pymethods]
impl Server {
/// Boot the frontend (spawns all threads) and return immediately.
/// `server_args` is the scheduler's [`ServerArgs`]; the rest are
/// rust-server-only overrides.
#[new]
#[pyo3(signature = (
server_args,
http_addr = None,
ingress_ring_cap = 8192,
egress_ring_cap = 8192,
to_scheduler_cap = 8192,
from_scheduler_cap = 8192,
channel_cap = 8192,
cores = None,
server_args_json = "{}",
))]
// pyo3 `#[new]` constructor: the wide arg list is the Python-facing boot
// surface (all optional overrides), not a call-site ergonomics problem.
#[allow(clippy::too_many_arguments)]
fn start(
server_args: ServerArgs,
http_addr: Option<String>,
ingress_ring_cap: usize,
egress_ring_cap: usize,
to_scheduler_cap: usize,
from_scheduler_cap: usize,
channel_cap: usize,
cores: Option<Vec<usize>>,
server_args_json: &str,
) -> PyResult<Self> {
// Static server metadata (server_args + model_config) dumped by the
// scheduler; parse and validate mandatory fields now so a bad/missing
// field is a boot error, not a request-time 500.
let server_args: runtime::ServerArgs = runtime::ServerArgs::from_json(server_args_json)
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"bad server_args_json: {e}"
))
})?;
server_args.validate_mandatory().map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("server_args: {e}"))
})?;
// The HTTP listen address, tokenizer source/threads/shards all live in the
// `server_args` blob; resolve them from there so the scheduler doesn't
// re-pass them. The explicit params stay as optional overrides for
// standalone callers (tests) that construct a `Server` without a full
// `server_args`.
// `server_args` already arrived typed (pyo3 rejected any missing/extra/
// mistyped field when Python constructed it); only value checks remain.
server_args
.validate()
.map_err(|e| value_error("server_args", e))?;
// The HTTP listen address, tokenizer source/threads/shards all live in
// `server_args`; resolve them from there so the scheduler doesn't re-pass
// them. The explicit params stay as optional overrides (per-DP-rank port,
// pinning) and for standalone callers.
let http_addr: SocketAddr = http_addr
.unwrap_or_else(|| server_args.bind())
.parse()
.map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("bad http_addr: {e}"))
})?;
.map_err(|e| value_error("bad http_addr", e))?;
let cfg = RuntimeConfig {
rust_server_args: runtime::RustServerServerArgs {
rust_server_args: RustServerServerArgs {
http_addr,
api_worker_num: server_args.api_worker_num(),
ingress_ring_cap,
egress_ring_cap,
http_api_worker_num: server_args.http_api_worker_num(),
to_scheduler_cap,
from_scheduler_cap,
channel_cap,
cores,
},
server_args: std::sync::Arc::new(server_args),
};
let rt = runtime::start(cfg).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("runtime start failed: {e}"))
})?;
let rt = runtime::start(cfg).map_err(|e| value_error("runtime start failed", e))?;
Ok(Server { rt })
}
/// Non-blocking drain of the ingress ring, returned **columnar** as an
/// [`IngressBatch`] so the large `input_ids` tensor never goes through
/// msgpack (see the field docs for the layout). The `ids` cells are copied
/// **directly into the result `bytes`** (one copy, no intermediate buffer).
///
/// Runs entirely GIL-held, deliberately. `drain` is a `try_recv` loop plus an
/// uncontended stash lock (the Python thread is the only consumer), so it
/// cannot block — there is nothing for a detach to overlap with. And detaching
/// is far from free: reacquiring the GIL waits out the interpreter's switch
/// interval, so a `py.detach` here cost up to 5 ms whenever another Python
/// thread was runnable, to cover ~0.2 µs of work. Held, the whole call is a
/// fraction of a microsecond on an empty ring.
/// Non-blocking drain of the to_scheduler channel, returned **columnar** as an
/// [`RequestBatch`] so the large `input_ids` tensor never goes through
/// msgpack (see the field docs for the layout).
#[pyo3(signature = (max = 256))]
fn recv_requests(&self, py: Python<'_>, max: usize) -> PyResult<IngressBatch> {
let cols = self.rt.ingress.drain(max);
fn recv_requests(&self, py: Python<'_>, max: usize) -> PyResult<RequestBatch> {
let cols = self.rt.to_scheduler_rx.drain(max);
let headers = cols
.headers
.iter()
.map(|h| PyBytes::new(py, h).unbind())
.collect();
// Single pass: copy each raw ids cell straight into the output `bytes`.
let data = PyBytes::new_with(py, cols.ids_total, |buf| {
let mut pos = 0;
for cell in &cols.ids {
let end = pos + cell.len();
buf[pos..end].copy_from_slice(cell);
pos = end;
}
cols.copy_ids_into(buf);
Ok(())
})?
.unbind();
Ok(IngressBatch {
})?;
Ok(RequestBatch {
headers,
data,
data: data.unbind(),
lengths: cols.lengths,
})
}
/// Park up to `timeout_ms` for an incoming request so the idle scheduler loop
/// sleeps instead of spinning at 100% CPU. Returns `True` when a request is
/// ready (the next `recv_requests` includes it). The GIL is released while
/// parked, and `flume` wakes the moment a request is pushed, so this adds no
/// latency to real requests — only the idle wait is bounded by `timeout_ms`.
/// sleeps instead of spinning at 100% CPU.
#[pyo3(signature = (timeout_ms = 1000))]
fn wait_ingress(&self, py: Python<'_>, timeout_ms: u64) -> bool {
fn wait_request(&self, py: Python<'_>, timeout_ms: u64) -> bool {
py.detach(|| {
self.rt
.ingress
.to_scheduler_rx
.wait(std::time::Duration::from_millis(timeout_ms))
})
}
@@ -186,73 +172,74 @@ impl Server {
/// Push a whole decode batch as ONE frame: a columnar msgpack `header` plus
/// the raw `data_cols` (per-column `bytes`), concatenated here. Blocks for
/// backpressure; `False` only on shutdown.
///
/// Framed and pushed with the GIL HELD, detaching only if the ring is full.
/// This runs on the scheduler's CUDA-launch thread every decode step, where the
/// unconditional detach was the single worst boundary cost: framing is
/// ~0.10.2 µs, but reacquiring the GIL waits out the interpreter's switch
/// interval (5 ms by default) whenever another Python thread is runnable —
/// 1750% of a 1030 ms decode step, landing nondeterministically. Held, the
/// whole boundary is ~1.3 µs per step.
///
/// The slow path keeps its detach because a full ring genuinely parks: the
/// scheduler must feel backpressure rather than drop output it has already
/// committed to. It essentially never fires — measured headroom is ~100×.
fn push_batch(&self, py: Python<'_>, header: &[u8], data_cols: Vec<PyBackedBytes>) -> bool {
fn push_decode_result_batch(
&self,
py: Python<'_>,
header: &[u8],
data_cols: Vec<PyBackedBytes>,
) -> bool {
let cols: Vec<&[u8]> = data_cols.iter().map(|d| d.as_ref()).collect();
self.push_frame(py, crate::message::frame_egress_batch_cols(header, &cols))
self.push_frame(
py,
crate::message::response::frame_decode_batch_cols(header, &cols),
)
}
/// Push a control-request result. Blocks for backpressure; `False` only on
/// shutdown.
fn push_result(&self, py: Python<'_>, rid: &str, payload: &[u8]) -> bool {
self.push_frame(py, crate::message::frame_egress_result(rid, payload))
fn push_control_result(&self, py: Python<'_>, rid: &str, payload: &[u8]) -> bool {
self.push_frame(
py,
crate::message::response::frame_control_result(rid, payload),
)
}
/// Route a terminal failure back to request `rid`. Blocks for backpressure;
/// `False` only on shutdown.
fn push_error(&self, py: Python<'_>, rid: &str, message: &str) -> bool {
self.push_frame(py, crate::message::frame_egress_error(rid, message))
self.push_frame(py, crate::message::response::frame_error(rid, message))
}
/// Spawn the MM worker pool for the pipeline in `spec_json` (built from the
/// resolved processor config; see `NativeMmHost.resolve_native_spec`).
/// Image-only requests are processed entirely in Rust and parked for
/// [`Server::take_mm`]; anything the pipeline cannot serve is rejected back to
/// the client — there is no Python fallback.
fn start_mm_workers(&self, spec_json: &str, workers: usize) -> PyResult<()> {
let ctx = mm::Context::new(
spec_json,
/// Spawn the MM worker pool for the pipeline in `spec` (built from the
/// resolved processor config; see `NativeMmHost.resolve_native_spec` and
/// `RustServer._build_mm_spec`). Image-only requests are processed entirely
/// in Rust and parked for [`Server::take_mm`]; anything the pipeline cannot
/// serve is rejected back to the client — there is no Python fallback.
fn start_mm_workers(&self, spec: MmSpec, workers: usize) -> PyResult<()> {
let ctx = multi_modality::worker::Context::new(
spec,
self.rt.tokenizer.clone(),
self.rt.mm_sidecar.clone(),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
.map_err(|e| value_error("mm spec", e))?;
self.rt.spawn_mm_pool(workers, std::sync::Arc::new(ctx));
Ok(())
}
/// Pop the MM result for `rid` — parked strictly before the request reached
/// the ingress ring — or `None` if there is none. The numeric buffers become
/// 1-D numpy arrays that take **ownership** of the Rust vectors, no copy.
/// the to_scheduler channel — or `None` if there is none. The numeric
/// buffers become 1-D numpy arrays that take **ownership** of the Rust
/// vectors, no copy.
///
/// Runs on the scheduler loop (`RustServer.drain`, under the GIL) between
/// decode steps, so any per-byte work here — memcpy or hashing, tens of MB
/// per image-heavy request — would stall every running request's ITL. Hence
/// the worker-precomputed `hashes`.
fn take_mm(&self, py: Python<'_>, rid: &str) -> Option<MmHandoff> {
/// Runs on the scheduler loop between decode steps, so any per-byte work
/// here — memcpy or hashing, tens of MB per image-heavy request — would
/// stall every running request's ITL. Hence the worker-precomputed `hashes`.
fn take_mm(&self, py: Python<'_>, rid: &str) -> Option<MmEncodeResult> {
use numpy::IntoPyArray;
let res = self.rt.mm_sidecar.take(rid)?;
let (features, shm_names) = match res.features {
mm::FeatureStore::Inline(v) => (Some(v.into_pyarray(py).unbind()), None),
multi_modality::sidecar::FeatureStore::Inline(v) => {
(Some(v.into_pyarray(py).unbind()), None)
}
// The segments — and the duty to unlink — move to Python here;
// `materialize()` unlinks after the post-broadcast clone on each rank.
mm::FeatureStore::Shm(segments) => (
multi_modality::sidecar::FeatureStore::Shm(segments) => (
None,
Some(segments.into_iter().map(|s| s.into_name()).collect()),
),
};
Some(MmHandoff {
Some(MmEncodeResult {
features,
shm_names,
grids: res.grids.iter().map(|g| (g[0], g[1], g[2])).collect(),
@@ -270,45 +257,32 @@ impl Server {
}
impl Server {
/// Hand one already-framed egress message to the ring: GIL-held when it fits,
/// detaching only to park on a full ring. Shared by every push path — they
/// differ solely in how the frame is built. `false` only on shutdown.
/// Hand one already-framed message to the ring. Shared by every push path —
/// they differ solely in how the frame is built. `false` only on shutdown.
#[inline]
fn push_frame(&self, py: Python<'_>, frame: bytes::Bytes) -> bool {
match self.rt.egress.try_push(frame) {
match self.rt.from_scheduler_tx.try_push(frame) {
Ok(()) => true,
// Consumer gone (shutdown): the frame is unavoidably lost.
Err(None) => false,
// Full: the scheduler must block here so backpressure reaches it, and
// blocking is exactly when releasing the GIL pays for itself.
Err(Some(frame)) => py.detach(|| self.rt.egress.push(frame)),
// Full: the scheduler must block here so backpressure reaches it.
Err(Some(frame)) => py.detach(|| self.rt.from_scheduler_tx.push(frame)),
}
}
}
/// Keeps the non-blocking log writer's background thread alive for the process
/// lifetime (dropping the guard would stop log delivery).
static LOG_GUARD: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
#[pymodule]
fn _server(m: &Bound<'_, PyModule>) -> PyResult<()> {
// Initialize tracing once; ignore if already set by the host process.
// Non-blocking writer: emitting threads (axum workers, egress, detok) only
// enqueue; a dedicated thread does the stdout formatting-flush + syscall.
// The queue is bounded and lossy — under extreme pressure log lines are
// dropped instead of stalling request threads.
let (writer, guard) = tracing_appender::non_blocking(std::io::stdout());
let _ = LOG_GUARD.set(guard);
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(writer)
.try_init();
logging::init_tracing();
m.add_class::<DisaggregationMode>()?;
m.add_class::<DefaultSamplingParams>()?;
m.add_class::<ModelConfig>()?;
m.add_class::<ServerArgs>()?;
m.add_class::<MmFamily>()?;
m.add_class::<MmResample>()?;
m.add_class::<MmSpec>()?;
m.add_class::<Server>()?;
m.add_class::<IngressBatch>()?;
m.add_class::<MmHandoff>()?;
m.add_class::<RequestBatch>()?;
m.add_class::<MmEncodeResult>()?;
Ok(())
}
+11 -94
View File
@@ -1,96 +1,13 @@
//! Messages moved between stages via `flume` (zero-copy moves); variable-length
//! buffers are `bytes::Bytes`, so egress fan-out to detok shards is a refcount bump.
//! Grouped by flow direction: [`request`] (the `/generate` body fan-out, the
//! in-flight request bodies + scheduler ingress wire), [`egress`]
//! (the response back-channel + egress-ring frames and decoded chunk events),
//! [`finish_reason`] (the terminal reason a request ended, Python's
//! `FinishReasonDict`), [`sampling`] (sampling-params normalization, the Python
//! `SamplingParams` port), [`io_struct`] (the scheduler wire structs), [`types`]
//! (the shared wire-shape adapters both directions use).
//! buffers are `bytes::Bytes`, so fanning one out to several detok shards is a
//! refcount bump, not a copy.
mod egress;
mod finish_reason;
mod io_struct;
pub mod mm_payload;
mod request;
mod sampling;
mod types;
pub use egress::{
ChunkEvent, ChunkExtras, EGRESS_TAG_BATCH, EGRESS_TAG_ERROR, EGRESS_TAG_RESULT, EgressItem,
EgressSink, SinkError, for_each_chunk, frame_egress_batch_cols, frame_egress_error,
frame_egress_result,
};
pub use finish_reason::Matched;
pub(crate) use io_struct::{AbortReq, ControlRequest, GetInternalStateReq};
pub use request::{GenerateBody, GenerateRequest, MmRequest, MmWorkItem, RequestKind};
// Constructed directly only by tests: `api_server::prefetch` fills its
// `prefetched` field, everything else gets it packed inside a `GenerateRequest`.
pub use request::MmData;
pub(crate) use sampling::{SamplingParams, SamplingParamsInput};
pub(crate) use types::{OneOrMany, OneOrManyItem, TokenIds};
use bytes::Bytes;
use crate::fsm::RequestState;
use crate::ids::Rid;
/// The owned request as it travels ingress stages (single owner, so `state` is
/// mutated lock-free). Common fields here; variant data in [`RequestKind`].
#[derive(Debug)]
pub struct Request {
/// Client-visible request id (uuid hex) — what the scheduler wire and
/// `meta_info.id` carry.
pub rid: Rid,
pub state: RequestState,
/// Back-channel to the client connection for egress frames.
pub sink: EgressSink,
/// Discriminant + variant body (generate vs control).
pub kind: RequestKind,
}
/// One ingress-ring entry, split columnar: the scalar `header` (msgpack, `input_ids`
/// omitted) + the raw int64 `ids` cell, so the big tensor never goes through msgpack.
#[derive(Debug)]
pub struct IngressMsg {
pub header: Bytes,
pub ids: Bytes,
}
/// Messages to a Detokenizer shard. `Register` carries the per-request sink for
/// the shard's local `rid -> sink` map. The rid STRING is the identity: `Rid::hash`
/// picks the shard (collisions there merely co-locate, which is harmless), but two
/// distinct rids that hash alike must not be the same map entry — that evicted one
/// client's sink and delivered their tokens to the other's connection. Equal rids
/// cannot reach here from different requests: `Rid::from_client` uniquifies every
/// client-supplied one.
pub enum DetokMsg {
Register {
/// Client-visible rid string — kept in `DetokState` so the shard can
/// emit `TmEvent::Abort(rid)` (the wire needs the string, not the hash).
rid: Rid,
sink: EgressSink,
/// Decode logprob token ids to text here (CPU-bound) not on the api threads.
decode_logprob_text: bool,
/// `SamplingParams.no_stop_trim`: keep the matched stop; default trims it.
no_stop_trim: bool,
},
/// One decode step's chunks for *this shard*. Batched because `tm-egress` blocks
/// per send, so one message per request cost ~1.3 µs × batch (5.1x at 4096).
Chunks(Vec<ChunkEvent>),
/// Decode a complete token-id sequence — the backend of
/// [`RequestKind::Detokenize`], the one request kind the detok stage itself
/// answers (it never reaches the scheduler ring). Sent by tm-ingress right
/// after the same rid's `Register` on the same channel (FIFO), so the shard
/// delivers the text through the registered sink like a control `Result`
/// and drops the entry.
Decode { rid: Rid, token_ids: Vec<u32> },
/// Control result: one already-serialized payload delivered to the sink verbatim.
Result { rid: Rid, payload: bytes::Bytes },
/// Terminal per-request failure → an `Error` to the sink (a 400, not a crash).
Fail { rid: Rid, message: String },
/// Drop the `rid -> sink` entry for a request rejected before the scheduler
/// (the rejecting stage already answered the client); else `Register` leaks one
/// entry.
Deregister { rid: Rid },
}
pub mod config;
pub mod detok;
pub mod finish_reason;
pub mod ids;
pub mod io_struct;
pub mod request;
pub mod response;
pub mod sampling;
pub mod types;
+638
View File
@@ -0,0 +1,638 @@
//! Runtime configuration: the rust-server boot knobs
//! ([`RustServerServerArgs`]), the scheduler's typed `server_args` handoff
//! ([`ServerArgs`] / [`ModelConfig`]), the [`RuntimeConfig`] pairing them for
//! `runtime::start`, and the native MM pipeline handoff ([`MmSpec`]).
//!
//! [`ServerArgs`] / [`ModelConfig`] / [`DefaultSamplingParams`] /
//! [`DisaggregationMode`] / [`MmSpec`] / [`MmFamily`] / [`MmResample`] are
//! also `#[pyclass]`es: the Python scheduler (`RustServer._build_server_args`
//! / `_build_mm_spec`) constructs them directly by keyword and hands them to
//! `Server`. There is one schema — this file — and
//! pyo3 enforces it at construction: every field is a required, typed
//! constructor argument, so a drifted caller fails at boot rather than running
//! on a silently-defaulted knob. The `#[pyo3::pymethods]` constructors below
//! each struct — plus the one hand-written extraction,
//! [`PreferredSamplingParams`] — are the only Python-facing code in this file;
//! the rest is pure Rust.
use std::net::SocketAddr;
use std::sync::Arc;
use serde::Serialize;
/// Boot knobs specific to the embedded rust server — none of these exist in
/// the Python-built [`ServerArgs`]; they arrive as explicit
/// `Server::start` parameters.
#[derive(Clone, Debug)]
pub struct RustServerServerArgs {
pub http_addr: SocketAddr,
pub http_api_worker_num: usize,
pub to_scheduler_cap: usize,
pub from_scheduler_cap: usize,
pub channel_cap: usize,
/// CPU core ids the pools pin to (e.g. this rank's NUMA-local cores minus
/// the scheduler's reserved launch cores). `None` → run unpinned.
pub cores: Option<Vec<usize>>,
}
impl Default for RustServerServerArgs {
fn default() -> Self {
Self {
http_addr: "127.0.0.1:30000".parse().unwrap(),
http_api_worker_num: 2,
to_scheduler_cap: 8192,
from_scheduler_cap: 8192,
channel_cap: 8192,
cores: None,
}
}
}
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
/// Rust-server-only boot knobs (listen address, pool/ring sizes, pinning).
pub rust_server_args: RustServerServerArgs,
/// The scheduler's [`ServerArgs`] (worker counts, tokenizer source,
/// config-endpoint metadata). `Arc` so cloning the config (and, downstream,
/// each `AppState`) is cheap; immutable after construction.
pub server_args: Arc<ServerArgs>,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
rust_server_args: RustServerServerArgs::default(),
server_args: Arc::new(ServerArgs::default()),
}
}
}
/// The scheduler's launch-time handoff (`RustServer._build_server_args`):
/// the `server_args` fields the rust server reads, the resolved
/// [`ModelConfig`], and launch-time stamps. Values are post-`__post_init__`
/// (all paths and names resolved). Constructed from Python via the `#[new]` in
/// `lib.rs`, whose keyword parameters are exactly these fields.
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
#[derive(Clone, Debug)]
pub struct ServerArgs {
/// HF repo id / local dir of the model, reported by `/get_model_info`.
pub model_path: String,
/// Model name reported by `/v1/models` and `/server_info`.
pub served_model_name: String,
/// Tokenizer source (model dir / `tokenizer.json` / HF repo id). Empty only
/// in standalone (test) configs — then boot requires `skip_tokenizer_init`.
pub tokenizer_path: String,
/// HF revision, used only when `tokenizer_path` is a repo id. `None` → main.
pub revision: Option<String>,
/// Weight format selected by `--load-format`, reported by `/get_model_info`.
/// The blob carries the post-`__post_init__` value (`auto` is already
/// narrowed to `gguf` / `mistral` / `runai_streamer` / `remote` where the
/// checkpoint demands it). Not consumed for loading -- the scheduler owns
/// that; `None` only when the blob omits the key.
pub load_format: Option<String>,
/// Operator-supplied weight version, reported by `/model_info`. Defaults to
/// `"default"` on the Python side, so it is present in every blob; `None`
/// only when the blob omits the key.
pub weight_version: Option<String>,
/// HTTP bind address (see [`Self::bind`]).
pub host: String,
pub port: u16,
/// Log levels driving the access log — uvicorn runs at
/// `log_level_http or log_level` (see [`Self::http_access_log_enabled`]).
pub log_level: String,
pub log_level_http: Option<String>,
/// Optional built-in chat-template name or path to a Jinja/legacy JSON
/// template file. Without an override, uses the tokenizer config template.
pub chat_template: Option<String>,
/// Parser selected by `--tool-call-parser`.
pub tool_call_parser: Option<String>,
/// Reasoning splitter selected by `--reasoning-parser` (e.g. deepseek-r1).
/// When set, chat completions strip the model's reasoning markers out of
/// `content` into `reasoning_content` — both unary and streaming.
pub reasoning_parser: Option<String>,
/// Python's global default for whether an SSE stream ends with a usage chunk.
pub stream_response_default_include_usage: bool,
/// Pinned tokenizer threads / detok shards (Python asserts both ≥ 1).
pub tokenizer_worker_num: usize,
pub detokenizer_worker_num: usize,
/// Token-ids-in / token-ids-out mode: no tokenizer load, raw `output_ids`
/// frames.
pub skip_tokenizer_init: bool,
/// Streamed `/generate` frames carry per-step deltas instead of cumulative
/// text. Matches the Python `TokenizerManager`.
pub incremental_streaming_output: bool,
/// PD-disaggregation role. (On prefill, the KV bootstrap registry is mounted
/// on the api router — see [`Self::enable_pd_bootstrap`].)
pub disaggregation_mode: DisaggregationMode,
/// The resolved Python `ModelConfig`, attached at handoff time.
pub model_config: ModelConfig,
/// Default sampling params advertised by `/get_model_info`, verbatim from
/// `server_args.preferred_sampling_params` (a JSON object or null).
pub preferred_sampling_params: Option<PreferredSamplingParams>,
/// Over-long inputs are truncated to fit the context instead of 400ing, and
/// `max_new_tokens` is clamped rather than rejected (Python
/// `TokenizerManager._validate_one_request`).
pub allow_auto_truncate: bool,
/// `return_hidden_states` is refused unless the server was launched with it:
/// the scheduler simply won't produce them, so the request would 200 with the
/// field silently missing.
pub enable_return_hidden_states: bool,
/// Output slots reserved per request on top of its input (eagle stores draft
/// tokens there). Not a `server_args` field — `TokenizerManager` derives it and
/// `RustServer._build_server_args` stamps it in, so both sides count alike.
pub num_reserved_tokens: u64,
/// Launch-time stamps (not `server_args` fields): sglang package version
/// and the scheduler-derived KV token capacity, reported by `/server_info`.
pub version: String,
pub max_total_num_tokens: u64,
}
#[pyo3::pymethods]
impl ServerArgs {
#[new]
#[pyo3(signature = (*,
model_path,
served_model_name,
tokenizer_path,
revision,
load_format,
weight_version,
host,
port,
log_level,
log_level_http,
chat_template,
tool_call_parser,
reasoning_parser,
stream_response_default_include_usage,
tokenizer_worker_num,
detokenizer_worker_num,
skip_tokenizer_init,
incremental_streaming_output,
disaggregation_mode,
model_config,
preferred_sampling_params,
allow_auto_truncate,
enable_return_hidden_states,
num_reserved_tokens,
version,
max_total_num_tokens,
))]
// The parameter list IS the schema; one keyword per field, all required.
#[allow(clippy::too_many_arguments)]
fn py_new(
model_path: String,
served_model_name: String,
tokenizer_path: String,
revision: Option<String>,
load_format: Option<String>,
weight_version: Option<String>,
host: String,
port: u16,
log_level: String,
log_level_http: Option<String>,
chat_template: Option<String>,
tool_call_parser: Option<String>,
reasoning_parser: Option<String>,
stream_response_default_include_usage: bool,
tokenizer_worker_num: usize,
detokenizer_worker_num: usize,
skip_tokenizer_init: bool,
incremental_streaming_output: bool,
disaggregation_mode: DisaggregationMode,
model_config: ModelConfig,
preferred_sampling_params: Option<PreferredSamplingParams>,
allow_auto_truncate: bool,
enable_return_hidden_states: bool,
num_reserved_tokens: u64,
version: String,
max_total_num_tokens: u64,
) -> Self {
Self {
model_path,
served_model_name,
tokenizer_path,
revision,
load_format,
weight_version,
host,
port,
log_level,
log_level_http,
chat_template,
tool_call_parser,
reasoning_parser,
stream_response_default_include_usage,
tokenizer_worker_num,
detokenizer_worker_num,
skip_tokenizer_init,
incremental_streaming_output,
disaggregation_mode,
model_config,
preferred_sampling_params,
allow_auto_truncate,
enable_return_hidden_states,
num_reserved_tokens,
version,
max_total_num_tokens,
}
}
}
impl Default for ServerArgs {
/// A standalone (test) config: no model, no tokenizer, unified role, but a
/// complete `model_config` so the runtime boots. Real launches never use
/// this — Python supplies every field.
fn default() -> Self {
Self {
model_path: String::new(),
served_model_name: String::new(),
tokenizer_path: String::new(),
revision: None,
load_format: None,
weight_version: None,
host: "127.0.0.1".into(),
port: 30000,
log_level: "info".into(),
log_level_http: None,
chat_template: None,
tool_call_parser: None,
reasoning_parser: None,
stream_response_default_include_usage: false,
tokenizer_worker_num: 1,
detokenizer_worker_num: 1,
skip_tokenizer_init: false,
incremental_streaming_output: false,
disaggregation_mode: DisaggregationMode::Null,
model_config: ModelConfig::default(),
preferred_sampling_params: None,
allow_auto_truncate: false,
enable_return_hidden_states: false,
num_reserved_tokens: 0,
version: String::new(),
max_total_num_tokens: 0,
}
}
}
/// `--preferred-sampling-params`, carried verbatim: `/get_model_info` echoes
/// whatever Python advertises, and the keys are whatever `SamplingParams`
/// accepts, so there is no fixed field list to model as a `#[pyclass]`.
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct PreferredSamplingParams(pub serde_json::Value);
impl<'py> pyo3::FromPyObject<'_, 'py> for PreferredSamplingParams {
type Error = pyo3::PyErr;
fn extract(obj: pyo3::Borrowed<'_, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
let text = obj.extract::<String>()?;
serde_json::from_str(&text).map(Self).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!(
"preferred_sampling_params is not valid JSON: {e}"
))
})
}
}
/// PD-disaggregation role, the values of `--disaggregation-mode`. Exposed to
/// Python as an enum (`DisaggregationMode.Null` / `.Prefill` / `.Decode`).
#[pyo3::pyclass(
eq,
frozen,
from_py_object,
module = "sglang.srt.rust_extensions._server"
)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisaggregationMode {
/// Unified prefill + decode.
Null,
Prefill,
Decode,
}
/// The slice of the resolved Python `ModelConfig` the rust server reads.
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
#[derive(Clone, Debug)]
pub struct ModelConfig {
/// Resolved context length (`max_model_len` in `/v1/models`); the ceiling
/// for input + `max_new_tokens`.
pub context_len: u64,
/// Bounds client-supplied token ids — return 400s out-of-vocab ids before
/// they crash the scheduler's embedding lookup.
pub vocab_size: u64,
/// Whether the model accepts multimodal inputs. Gates the MM Encoding branch
/// in to-scheduler; `false` silently ignores mm fields, as the Python
/// `TokenizerManager` does with `mm_processor is None`.
pub is_multimodal: bool,
/// Resolved default sampling parameters, from Python's
/// `ModelConfig.get_default_sampling_params()`. Already gated on
/// `--sampling-defaults`: holds the model's generation_config.json values
/// in "model" mode, and is all-`None` in "openai" mode. Consumed when a chat
/// request omits `temperature`/`top_p` — the conversion must not skip
/// straight to the OpenAI terminal defaults.
pub default_sampling_params: DefaultSamplingParams,
}
#[pyo3::pymethods]
impl ModelConfig {
#[new]
#[pyo3(signature = (*, context_len, vocab_size, is_multimodal, default_sampling_params))]
fn py_new(
context_len: u64,
vocab_size: u64,
is_multimodal: bool,
default_sampling_params: DefaultSamplingParams,
) -> Self {
Self {
context_len,
vocab_size,
is_multimodal,
default_sampling_params,
}
}
}
impl Default for ModelConfig {
/// Test-only: a small but complete model so the runtime boots.
fn default() -> Self {
Self {
context_len: 2048,
vocab_size: 1000,
is_multimodal: false,
default_sampling_params: DefaultSamplingParams::default(),
}
}
}
/// One `SamplingParams` field per key `get_default_sampling_params()` may emit
/// (`repetition_penalty`, `temperature`, `top_k`, `top_p`, `min_p`); `None`
/// where the generation config does not set it.
///
/// `top_k` / `min_p` / `repetition_penalty` are carried for parity with the
/// Python dict but not yet consumed: the Dynamo chat request type only carries
/// `temperature` and `top_p`, so the conversion resolves just those two.
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
#[derive(Clone, Debug, Default)]
#[allow(dead_code)]
pub struct DefaultSamplingParams {
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub top_k: Option<i64>,
pub min_p: Option<f64>,
pub repetition_penalty: Option<f64>,
}
#[pyo3::pymethods]
impl DefaultSamplingParams {
#[new]
#[pyo3(signature = (*, temperature = None, top_p = None, top_k = None, min_p = None, repetition_penalty = None))]
fn py_new(
temperature: Option<f64>,
top_p: Option<f64>,
top_k: Option<i64>,
min_p: Option<f64>,
repetition_penalty: Option<f64>,
) -> Self {
Self {
temperature,
top_p,
top_k,
min_p,
repetition_penalty,
}
}
}
/// The native MM pipeline handoff, built by `RustServer._build_mm_spec` from
/// the resolved `NativeMmSpec` and passed to `Server.start_mm_workers`. Same
/// contract as [`ServerArgs`]: every field is a required, typed constructor
/// keyword, so a drifted Python caller fails at boot.
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
#[derive(Clone, Debug)]
pub struct MmSpec {
/// Park feature buffers in POSIX shm rather than inline. Set by the Python
/// launcher (`NativeMmHost._use_feature_shm`) exactly when the scheduler
/// broadcasts across TP ranks and will unwrap `ShmPointerMMData`.
pub feature_shm: bool,
/// The family pipeline and its resolved processor parameters.
pub pipeline: sglang_mm::registry::PipelineSpec,
}
#[pyo3::pymethods]
impl MmSpec {
/// The parameter list is flat because every family so far shares the
/// Qwen-VL processor geometry; a family with different knobs adds its own
/// keywords and match arm here.
#[new]
#[pyo3(signature = (*,
family,
feature_shm,
image_token_id,
patch_size,
merge_size,
temporal_patch_size,
min_pixels,
max_pixels,
image_mean,
image_std,
resample,
))]
#[allow(clippy::too_many_arguments)]
fn py_new(
family: MmFamily,
feature_shm: bool,
image_token_id: i32,
patch_size: usize,
merge_size: usize,
temporal_patch_size: usize,
min_pixels: usize,
max_pixels: usize,
image_mean: [f32; 3],
image_std: [f32; 3],
resample: MmResample,
) -> Self {
use sglang_mm::registry::PipelineSpec;
let pipeline = match family {
MmFamily::QwenVl => PipelineSpec::QwenVl(sglang_mm::qwen_vl::QwenVlSpec {
image_token_id,
patch_size,
merge_size,
temporal_patch_size,
min_pixels,
max_pixels,
image_mean,
image_std,
resample: resample.into(),
}),
};
Self {
feature_shm,
pipeline,
}
}
}
/// Which `sglang_mm` family pipeline serves the model — one variant per
/// [`sglang_mm::registry::PipelineSpec`] arm. Exposed to Python as an enum
/// (`MmFamily.QwenVl`); `NativeMmFamily.name` maps onto it at handoff.
#[pyo3::pyclass(
eq,
frozen,
from_py_object,
module = "sglang.srt.rust_extensions._server"
)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MmFamily {
QwenVl,
}
/// The HF image processor the native resize must reproduce bit-exactly (see
/// [`sglang_mm::qwen_vl::Resampler`]). Exposed to Python as an enum
/// (`MmResample.AtenU8` / `.Pil`); `NativeMmFamily.image_processors` maps each
/// processor class onto it.
#[pyo3::pyclass(
eq,
frozen,
from_py_object,
module = "sglang.srt.rust_extensions._server"
)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MmResample {
/// `Qwen2VLImageProcessor` / `…Fast` — torchvision on a uint8 tensor.
AtenU8,
/// `Qwen2VLImageProcessorPil`, behind `--disable-fast-image-processor`.
Pil,
}
impl From<MmResample> for sglang_mm::qwen_vl::Resampler {
fn from(r: MmResample) -> Self {
match r {
MmResample::AtenU8 => Self::AtenU8,
MmResample::Pil => Self::Pil,
}
}
}
fn join_host_port(host: &str, port: u16) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}") // bare IPv6 (`::`) needs brackets to bind
} else {
format!("{host}:{port}")
}
}
impl ServerArgs {
/// Fail fast at startup on values the types cannot express.
pub fn validate(&self) -> Result<(), String> {
if self.served_model_name.is_empty() {
return Err("empty 'served_model_name' in server_args".into());
}
Ok(())
}
/// True on a prefill or decode node — requests need bootstrap routing.
pub fn is_disaggregation(&self) -> bool {
self.disaggregation_mode != DisaggregationMode::Null
}
/// Serve the PD KV bootstrap registry on the api listener: every prefill
/// rust server hosts it, unconditionally — no extra topology gating. KV
/// managers and decode nodes reach the registry at the resolved
/// `disaggregation_bootstrap_port`, which rust-server mode aliases to the
/// api port, so whichever prefill server that port names is the one that
/// receives the registrations.
pub fn enable_pd_bootstrap(&self) -> bool {
self.disaggregation_mode == DisaggregationMode::Prefill
}
/// Whether the served model is multimodal, from the scheduler's config. See
/// [`ModelConfig::is_multimodal`].
pub fn model_is_multimodal(&self) -> bool {
self.model_config.is_multimodal
}
/// Bind address `host:port`. `host` is expected to be an IP — the result is
/// parsed as a `SocketAddr`, so a bare IPv6 host gets bracketed.
pub fn bind(&self) -> String {
join_host_port(&self.host, self.port)
}
/// Whether the HTTP access log is emitted, mirroring the Python server:
/// uvicorn runs at `log_level_http or log_level` and prints access lines
/// only at info/debug. `--log-level-http warning` turns them off.
pub fn http_access_log_enabled(&self) -> bool {
let level = self
.log_level_http
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&self.log_level);
matches!(
level.to_ascii_lowercase().as_str(),
"trace" | "debug" | "info"
)
}
/// Pinned API threads for the embedded HTTP api-server. Python `server_args`
/// has no such field — this is derived: enough to cover the widest pool.
pub fn http_api_worker_num(&self) -> usize {
4.max(self.tokenizer_worker_num)
.max(self.detokenizer_worker_num)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bind_brackets_bare_ipv6() {
let sa = ServerArgs {
host: "::".into(),
port: 30001,
..Default::default()
};
assert_eq!(sa.bind(), "[::]:30001");
assert_eq!(ServerArgs::default().bind(), "127.0.0.1:30000");
}
#[test]
fn pd_role_derivations() {
let prefill = ServerArgs {
disaggregation_mode: DisaggregationMode::Prefill,
..Default::default()
};
assert!(prefill.is_disaggregation());
assert!(prefill.enable_pd_bootstrap());
let decode = ServerArgs {
disaggregation_mode: DisaggregationMode::Decode,
..Default::default()
};
assert!(decode.is_disaggregation());
assert!(!decode.enable_pd_bootstrap());
assert!(!ServerArgs::default().is_disaggregation());
}
#[test]
fn validate_requires_served_model_name() {
assert!(ServerArgs::default().validate().is_err());
let sa = ServerArgs {
served_model_name: "m".into(),
..Default::default()
};
assert!(sa.validate().is_ok());
}
/// `--log-level-http` overrides `--log-level` for the access log; unset or
/// empty falls through.
#[test]
fn access_log_follows_http_level_then_global() {
let mut sa = ServerArgs::default();
assert!(sa.http_access_log_enabled());
sa.log_level_http = Some("warning".into());
assert!(!sa.http_access_log_enabled());
sa.log_level_http = Some(String::new());
sa.log_level = "error".into();
assert!(!sa.http_access_log_enabled());
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Messages to a Detokenizer shard.
use super::ids::Rid;
use super::response::{ChunkEvent, ResponseSink};
/// Messages to a Detokenizer shard. `Register` carries the per-request sink for
/// the shard's local `rid -> sink` map. The rid STRING is the identity: `Rid::hash`
/// picks the shard (collisions there merely co-locate, which is harmless), but two
/// distinct rids that hash alike must not be the same map entry — that evicted one
/// client's sink and delivered their tokens to the other's connection. Equal rids
/// cannot reach here from different requests: `Rid::from_client` uniquifies every
/// client-supplied one.
pub enum DetokMsg {
Register {
/// Client-visible rid string — kept in `DetokState` so the shard can
/// emit `TmEvent::Abort(rid)` (the wire needs the string, not the hash).
rid: Rid,
sink: ResponseSink,
/// Decode logprob token ids to text here (CPU-bound) not on the api threads.
decode_logprob_text: bool,
/// `SamplingParams.no_stop_trim`: keep the matched stop; default trims it.
no_stop_trim: bool,
},
/// One decode step's chunks for *this shard*. Batched because `from-scheduler` blocks
/// per send.
Chunks(Vec<ChunkEvent>),
/// Decode a complete token-id sequence — the backend of
/// [`RequestKind::Detokenize`](super::RequestKind::Detokenize), the one
/// request kind the detok stage itself answers (it never reaches the
/// scheduler ring). Sent by to-scheduler right after the same rid's `Register`
/// on the same channel (FIFO), so the shard delivers the text through the
/// registered sink like a control `Result` and drops the entry.
Decode { rid: Rid, token_ids: Vec<u32> },
/// Control result: one already-serialized payload delivered to the sink verbatim.
Result { rid: Rid, payload: bytes::Bytes },
/// Terminal per-request failure → an `Error` to the sink (a 400, not a crash).
Fail { rid: Rid, message: String },
/// Drop the `rid -> sink` entry for a request rejected before the scheduler
/// (the rejecting stage already answered the client); else `Register` leaks one
/// entry.
Deregister { rid: Rid },
}
@@ -1,8 +1,6 @@
//! The terminal finish reason: Python's `FinishReasonDict` — what
//! `BaseFinishReason.to_json()` (schedule_batch.py) puts on the egress wire, and
//! what the API echoes back as `meta_info.finish_reason`. Ingress has no
//! counterpart; it rides in the [`BatchHeader`](super::egress::BatchHeader) and on
//! each terminal [`ChunkEvent`](super::ChunkEvent).
//! `BaseFinishReason.to_json()` (schedule_batch.py) puts on the response, and
//! what the API echoes back as `meta_info.finish_reason`.
use serde::{Deserialize, Serialize};
@@ -115,8 +115,7 @@ impl Rid {
}
}
/// Shard index for `n` detokenizer shards. Pure function of the id so the
/// ingress and egress sides agree without any shared map.
/// Shard index for `n` detokenizer shards.
#[inline]
pub fn shard(&self, n: usize) -> usize {
debug_assert!(n > 0);
@@ -126,16 +125,9 @@ impl Rid {
impl From<String> for Rid {
fn from(id: String) -> Self {
// ONE seed per process, not one per conversion. Ingress and egress each
// ONE seed per process, not one per conversion. To-scheduler and from-scheduler each
// build a `Rid` from the same string and must agree on the shard without a
// shared map — a fresh `RandomState` here would hash the same rid two
// different ways, so chunks would arrive at a shard that never registered
// the request and be dropped.
//
// The seed is random rather than fixed because rids are client-supplied:
// with public keys, colliding rids are an offline ~2^32 search. Collisions
// are only a shard co-location now (identity is the string), but a keyed
// hash also stops an attacker from stacking every request onto one shard.
// shared map.
static SEED: OnceLock<RandomState> = OnceLock::new();
let hash = SEED.get_or_init(RandomState::new).hash_one(&id);
Rid { id, hash }
+4 -2
View File
@@ -6,9 +6,11 @@
use bytes::Bytes;
use serde::Serialize;
use super::request::GenerateRequest;
use super::sampling::SamplingParams;
use super::types::TokenIds;
use super::types::{Tagged, control_messages, wire_struct};
use super::{GenerateRequest, SamplingParams, TokenIds};
use crate::error::Error;
use crate::utils::error::Error;
wire_struct! {
/// The scheduler's `TokenizedGenerateReqInput`. Keep in lockstep with the
+44 -18
View File
@@ -1,7 +1,5 @@
//! The `/generate` request path: the HTTP body and its per-request fan-out
//! ([`GenerateBody`] → [`GenerateRequest`]s), the variant bodies, and the
//! scheduler ingress encodings (`TokenizedGenerateReqInput` header,
//! control/abort, `IngressMsg`).
//! ([`GenerateBody`] → [`GenerateRequest`]s).
use std::collections::HashSet;
use std::sync::LazyLock;
@@ -11,10 +9,12 @@ use itertools::izip;
use serde::Deserialize;
use super::io_struct::{ControlRequest, TokenizedGenerateReqInput};
use super::{OneOrMany, OneOrManyItem, SamplingParams, SamplingParamsInput, TokenIds};
use crate::environ::env_u64;
use crate::error::Error;
use crate::ids::Rid;
use super::response::ResponseSink;
use super::sampling::{SamplingParams, SamplingParamsInput};
use super::types::{OneOrMany, OneOrManyItem, TokenIds};
use crate::message::ids::Rid;
use crate::utils::fsm::RequestState;
use crate::utils::{environ::env_u64, error::Error};
/// Hard cap on how many scheduler requests one `/generate` HTTP call may expand
/// into. Every column below is allocated per item before anything is dispatched,
@@ -519,12 +519,12 @@ fn split_mm_column(
/// plus the owned inputs from [`GenerateRequest::take_mm_work`].
#[derive(Debug)]
pub struct MmRequest {
pub rid: crate::ids::Rid,
pub rid: Rid,
pub work: MmWorkItem,
}
/// The parked request's fields the MM worker owns; converted to the driver input
/// by [`super::mm_payload::to_mm_input`].
/// by [`crate::multi_modality::payload::to_mm_input`].
#[derive(Debug, Default)]
pub struct MmWorkItem {
pub text: Option<String>,
@@ -541,17 +541,40 @@ pub struct MmWorkItem {
/// Whether an optional mm field counts as multimodal input, via the same
/// `value_present` the MM worker's payload parser uses.
fn mm_value_present(v: &Option<rmpv::Value>) -> bool {
v.as_ref().is_some_and(super::mm_payload::value_present)
v.as_ref()
.is_some_and(crate::multi_modality::payload::value_present)
}
/// Request variant — selects the ingress branch, scheduler wire message, and
/// egress shape. Each owns its body, so generate/control fields stay type-separate.
/// The owned request as it travels request stages (single owner, so `state` is
/// mutated lock-free). Common fields here; variant data in [`RequestKind`].
#[derive(Debug)]
pub struct Request {
/// Client-visible request id (uuid hex) — what the scheduler wire and
/// `meta_info.id` carry.
pub rid: Rid,
pub state: RequestState,
/// Back-channel to the client connection for response frames.
pub sink: ResponseSink,
/// Discriminant + variant body (generate vs control).
pub kind: RequestKind,
}
/// One to_scheduler channel entry, split columnar: the scalar `header` (msgpack, `input_ids`
/// omitted) + the raw int64 `ids` cell, so the big tensor never goes through msgpack.
#[derive(Debug)]
pub struct SchedulerRequest {
pub header: Bytes,
pub ids: Bytes,
}
/// Request variant — selects the request branch, scheduler wire message, and
/// response shape. Each owns its body, so generate/control fields stay type-separate.
#[derive(Debug)]
pub enum RequestKind {
/// `/generate`: tokenize (if needed) then push a `TokenizedGenerateReqInput`.
Generate(Box<GenerateRequest>),
/// A control endpoint (e.g. `/server_info`, `/health`): no tokenization, and
/// the egress is a single non-streamed JSON result.
/// the response is a single non-streamed JSON result.
Control(Box<ControlRequest>),
/// Internal service call: decode a complete token-id sequence to text. Walks
/// the same FSM as every request (validate → register → Queued), but the
@@ -595,7 +618,7 @@ pub struct GenerateRequest {
/// by the pool before the header is built; never reaches the scheduler wire.
pub skip_special_tokens: bool,
/// Sampling params (defaults when the client sent none, as in Python);
/// normalized + verified at ingress, then serialized into the header.
/// normalized + verified, then serialized into the header.
pub sampling_params: SamplingParams,
/// Whether the client asked for SSE streaming.
pub stream: bool,
@@ -639,13 +662,16 @@ pub struct GenerateRequest {
}
/// The opaque multimodal fields of one request (see [`GenerateRequest::mm`]).
///
/// Constructed directly only by tests: `api_server::prefetch` fills its
/// `prefetched` field, everything else gets it packed inside a `GenerateRequest`.
#[derive(Debug, Default)]
pub struct MmData {
pub image_data: Option<rmpv::Value>,
pub video_data: Option<rmpv::Value>,
pub audio_data: Option<rmpv::Value>,
/// Bytes of `image_data`'s I/O-backed sources, resolved by
/// `api_server::prefetch` in `mm_payload::io_sources` order so MM workers
/// `api_server::prefetch` in `payload::io_sources` order so MM workers
/// never block on I/O. Out-of-band: the values above stay as the client
/// sent them.
pub prefetched: Vec<bytes::Bytes>,
@@ -693,8 +719,8 @@ impl GenerateRequest {
}
/// `input_ids` widened to raw little-endian int64 bytes (the scheduler's
/// `array("q")` columnar cell — rides the ingress ring outside msgpack). Empty
/// when not tokenized.
/// `array("q")` columnar cell — rides the to-scheduler channel outside
/// msgpack). Empty when not tokenized.
pub fn encode_data_buf(&self) -> Bytes {
let ids = self.input_ids.as_deref().unwrap_or(&[]);
let mut buf = Vec::with_capacity(ids.len() * 8);
@@ -882,7 +908,7 @@ mod tests {
assert!(requests(r#"{"text": "a", "input_ids": [1]}"#).is_err());
assert!(requests(r#"{"stream": true}"#).is_err());
// Parallel sampling is rejected where Python reads it — in the params,
// at normalization (the ingress step), not here.
// at normalization, not here.
let (mut ps, _) = requests(r#"{"text": "a", "sampling_params": {"n": 2}}"#).unwrap();
assert!(ps[0].sampling_params.normalize(false, TEST_VOCAB).is_err());
}
@@ -1,25 +1,25 @@
//! The egress (response) direction: the per-request back-channel the API
//! handler drains ([`EgressSink`] / [`EgressItem`]), the egress-ring frame
//! encodings (batch / control result / error), and the columnar batch decode
//! into per-request [`ChunkEvent`]s.
//! The response direction: the per-request back-channel the API handler
//! drains ([`ResponseSink`] / [`ResponseItem`]), the response frame encodings
//! (batch / control result / error), and the columnar batch decode into
//! per-request [`ChunkEvent`]s.
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use super::TokenIds;
use super::finish_reason::FinishReason;
use crate::error::Error;
use crate::ids::Rid;
use super::types::TokenIds;
use crate::message::ids::Rid;
use crate::utils::error::Error;
/// Per-request back-channel the detok shard writes egress frames to and the API
/// Per-request back-channel the detok shard writes decode frames to and the API
/// handler drains for SSE; bounded, and receiver-drop (disconnect) = stream end.
#[derive(Clone, Debug)]
pub enum EgressSink {
Local(mpsc::Sender<EgressItem>),
pub enum ResponseSink {
Local(mpsc::Sender<ResponseItem>),
}
/// Why an [`EgressSink::try_send`] failed: `Full` = client backpressure, `Closed`
/// Why an [`ResponseSink::try_send`] failed: `Full` = client backpressure, `Closed`
/// = client gone. Both terminal for a stream; the caller distinguishes for logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SinkError {
@@ -27,11 +27,11 @@ pub enum SinkError {
Closed,
}
impl EgressSink {
impl ResponseSink {
/// Non-blocking send. `Err(Full)` = backpressure, `Err(Closed)` = client gone.
pub fn try_send(&self, item: EgressItem) -> Result<(), SinkError> {
pub fn try_send(&self, item: ResponseItem) -> Result<(), SinkError> {
match self {
EgressSink::Local(tx) => tx.try_send(item).map_err(|e| match e {
ResponseSink::Local(tx) => tx.try_send(item).map_err(|e| match e {
mpsc::error::TrySendError::Full(_) => SinkError::Full,
mpsc::error::TrySendError::Closed(_) => SinkError::Closed,
}),
@@ -40,12 +40,12 @@ impl EgressSink {
}
#[allow(dead_code)] // the receiver half is created inline in api_server::submit.
pub type EgressSource = mpsc::Receiver<EgressItem>;
pub type ResponseSource = mpsc::Receiver<ResponseItem>;
/// What the connection handler receives on the egress stream: a detok-decoded
/// What the connection handler receives on the decode stream: a detok-decoded
/// [`ChunkEvent`] (handler formats it), a verbatim control payload, or an error.
#[derive(Debug)]
pub enum EgressItem {
pub enum ResponseItem {
/// An intermediate streamed generation step (only sent for streaming reqs).
Frame(ChunkEvent),
/// The final generation step.
@@ -62,15 +62,15 @@ pub enum EgressItem {
Error(Error),
}
/// Egress-ring frame tag (first byte, prepended Rust-side; Python wire unchanged):
/// Response frame tag (first byte, prepended Rust-side; Python wire unchanged):
/// a single control-request result payload.
pub const EGRESS_TAG_RESULT: u8 = 1;
pub const DISPATCH_TAG_RESULT: u8 = 1;
/// A whole decode batch: msgpack columnar header + one concatenated raw buffer;
/// tm-egress decodes it into per-request [`ChunkEvent`]s (no per-request FFI).
pub const EGRESS_TAG_BATCH: u8 = 2;
/// from-scheduler decodes it into per-request [`ChunkEvent`]s (no per-request FFI).
pub const DISPATCH_TAG_BATCH: u8 = 2;
/// A per-request failure `[rid, message]`: the Python drain couldn't decode a
/// header, so it routes a 400 back to that request instead of crashing the loop.
pub const EGRESS_TAG_ERROR: u8 = 3;
pub const DISPATCH_TAG_ERROR: u8 = 3;
/// Read `n` little-endian f32s from `data` at `*off`, advancing `*off`. `None` when
/// the range runs past the buffer (a malformed / positional-ABI-drifted frame): the
@@ -105,11 +105,11 @@ fn take_i32(data: &[u8], off: &mut usize, n: usize) -> Option<Vec<i32>> {
/// Frame a decode batch: `[BATCH tag][u32 header len][header][data cols…]`. The
/// caller's `data_cols` are concatenated straight into the frame (one copy, no
/// `b"".join`); `header` is the msgpack [`BatchHeader`]. Runs off the GIL.
pub fn frame_egress_batch_cols(header: &[u8], data_cols: &[&[u8]]) -> Bytes {
/// `b"".join`); `header` is the msgpack [`BatchHeader`].
pub fn frame_decode_batch_cols(header: &[u8], data_cols: &[&[u8]]) -> Bytes {
let data_len: usize = data_cols.iter().map(|c| c.len()).sum();
let mut buf = Vec::with_capacity(1 + 4 + header.len() + data_len);
buf.push(EGRESS_TAG_BATCH);
buf.push(DISPATCH_TAG_BATCH);
buf.extend_from_slice(&(header.len() as u32).to_le_bytes());
buf.extend_from_slice(header);
for col in data_cols {
@@ -213,13 +213,13 @@ fn take_hidden(
Some((take_f32(data, cv, nv)?, lens))
}
/// Decode a batch egress frame (tag stripped), calling `route` with each request's
/// Decode a batch frame (tag stripped), calling `route` with each request's
/// [`ChunkEvent`] as it's decoded — one pass, no intermediate `Vec`, peak memory
/// one request. Column order matches `push_generation`.
///
/// `ok == false` means the frame was rejected. The caller discards everything it
/// routed and fails the frame's requests instead of forwarding a partial fan-out
/// (see `tokenizer_manager::egress`), so a rejected frame delivers nothing —
/// (see `tokenizer_manager::from_scheduler`), so a rejected frame delivers nothing —
/// `rids` exists precisely so those requests can be failed rather than left
/// waiting for a `Done` that no longer exists.
pub fn for_each_chunk(body: &[u8], mut route: impl FnMut(ChunkEvent)) -> Decoded {
@@ -498,23 +498,23 @@ pub struct Decoded {
pub rids: Vec<Rid>,
}
/// Frame a control result `[rid, payload]` for the egress ring (tag prepended).
pub fn frame_egress_result(rid: &str, payload: &[u8]) -> Bytes {
/// Frame a control result `[rid, payload]` for the response ring (tag prepended).
pub fn frame_control_result(rid: &str, payload: &[u8]) -> Bytes {
use rmpv::Value;
let arr = Value::Array(vec![Value::from(rid), Value::Binary(payload.to_vec())]);
let mut buf = Vec::with_capacity(1 + payload.len() + rid.len() + 8);
buf.push(EGRESS_TAG_RESULT);
buf.push(DISPATCH_TAG_RESULT);
let _ = rmpv::encode::write_value(&mut buf, &arr);
Bytes::from(buf)
}
/// Frame a per-request failure `[rid, message]` for the egress ring — routes a
/// Frame a per-request failure `[rid, message]` for the response — routes a
/// terminal error back to the owning request (→ HTTP 400) instead of crashing.
pub fn frame_egress_error(rid: &str, message: &str) -> Bytes {
pub fn frame_error(rid: &str, message: &str) -> Bytes {
use rmpv::Value;
let arr = Value::Array(vec![Value::from(rid), Value::from(message)]);
let mut buf = Vec::with_capacity(1 + rid.len() + message.len() + 8);
buf.push(EGRESS_TAG_ERROR);
buf.push(DISPATCH_TAG_ERROR);
let _ = rmpv::encode::write_value(&mut buf, &arr);
Bytes::from(buf)
}
@@ -618,11 +618,11 @@ mod tests {
let header = [1u8, 2, 3];
let a = [10u8, 11];
let b = [12u8, 13, 14];
let multi = frame_egress_batch_cols(&header, &[&a[..], &b[..]]);
let multi = frame_decode_batch_cols(&header, &[&a[..], &b[..]]);
let joined: Vec<u8> = a.iter().chain(&b).copied().collect();
let single = frame_egress_batch_cols(&header, &[joined.as_slice()]);
let single = frame_decode_batch_cols(&header, &[joined.as_slice()]);
assert_eq!(multi, single);
assert_eq!(multi[0], EGRESS_TAG_BATCH);
assert_eq!(multi[0], DISPATCH_TAG_BATCH);
assert_eq!(
u32::from_le_bytes([multi[1], multi[2], multi[3], multi[4]]),
3
@@ -664,8 +664,8 @@ mod tests {
.flat_map(|x| x.to_le_bytes())
.collect();
let framed = frame_egress_batch_cols(&header, &[&data]);
assert_eq!(framed[0], EGRESS_TAG_BATCH);
let framed = frame_decode_batch_cols(&header, &[&data]);
assert_eq!(framed[0], DISPATCH_TAG_BATCH);
let mut events = Vec::new();
assert!(for_each_chunk(&framed[1..], |ev| events.push(ev)).ok);
assert_eq!(events.len(), 3);
@@ -690,13 +690,13 @@ mod tests {
assert_eq!(events[2].prompt_tokens, 6);
// A plain decode frame carries no extras columns at all, so the per-frame
// `has_extras` guard must skip the extras machinery entirely for every
// request (this is the tm-egress hot path — see `for_each_chunk`).
// request (this is the from-scheduler hot path — see `for_each_chunk`).
assert!(events.iter().all(|e| e.extras.is_none()));
}
/// A header whose column lengths exceed the data buffer (a Python/Rust
/// positional-ABI drift, or a truncated frame) is rejected: `for_each_chunk`
/// returns false and routes nothing — it must NOT panic the sole egress thread
/// returns false and routes nothing — it must NOT panic the sole from_scheduler thread
/// on an out-of-bounds slice. Built the way Python emits (positional msgpack
/// header + concatenated data columns).
#[test]
@@ -717,7 +717,7 @@ mod tests {
rmpv::encode::write_value(&mut header, &header_arr).unwrap();
let data: Vec<u8> = [0i32].iter().flat_map(|x| x.to_le_bytes()).collect(); // 4 bytes
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut routed = 0usize;
let decoded = for_each_chunk(&framed[1..], |_| routed += 1);
assert!(!decoded.ok, "malformed frame must be rejected, not decoded");
@@ -759,7 +759,7 @@ mod tests {
data.extend(i(&[10, 20])); // token_ids
data.extend(f(&[-0.1, -0.2, -0.3, -0.4])); // out_top_val (sum of poslens = 4)
data.extend(i(&[1, 2, 3, 4])); // out_top_idx
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut routed = 0usize;
assert!(
!for_each_chunk(&framed[1..], |_| routed += 1).ok,
@@ -790,7 +790,7 @@ mod tests {
let mut data = Vec::new();
data.extend(i(&[10, 20])); // token_ids
data.extend(f(&[0.1, 0.2, 0.3])); // hidden_val (sum of poslens = 3)
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut routed = 0usize;
assert!(
!for_each_chunk(&framed[1..], |_| routed += 1).ok,
@@ -812,7 +812,7 @@ mod tests {
]);
let mut header = Vec::new();
rmpv::encode::write_value(&mut header, &header_arr).unwrap();
let framed = frame_egress_batch_cols(&header, &[&[0u8; 4][..]]);
let framed = frame_decode_batch_cols(&header, &[&[0u8; 4][..]]);
let mut routed = 0usize;
let decoded = for_each_chunk(&framed[1..], |_| routed += 1);
assert!(!decoded.ok);
@@ -839,12 +839,12 @@ mod tests {
let mut header = Vec::new();
rmpv::encode::write_value(&mut header, &header_arr).unwrap();
let data: Vec<u8> = vec![0u8; 8]; // 4 bytes too many
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let decoded = for_each_chunk(&framed[1..], |_| {});
assert!(!decoded.ok, "header and data must agree exactly");
}
/// Ingress/egress rid agreement: the rid decoded from the frame must be the
/// Request/response rid agreement: the rid decoded from the frame must be the
/// one Python sent, AND both sides must derive the same shard from it. The
/// partition key is memoized inside `Rid`, so a per-conversion hasher seed
/// would send a request's chunks to a shard that never registered it.
@@ -862,7 +862,7 @@ mod tests {
rmpv::encode::write_value(&mut header, &header_arr).unwrap();
let data: Vec<u8> = [0i32].iter().flat_map(|x| x.to_le_bytes()).collect();
let framed = frame_egress_batch_cols(&header, &[&data]);
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_eq!(events.len(), 1);
@@ -931,7 +931,7 @@ mod tests {
data.extend(i(&[10, 11])); // out_top_idx
data.extend(f(&[0.1, 0.2, 0.3])); // hidden_val (1 row, dim 3)
let framed = frame_egress_batch_cols(&header, &[&data]);
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_eq!(events.len(), 2);
@@ -999,7 +999,7 @@ mod tests {
data.extend(i(&[10, 20])); // token_ids
data.extend(f(&[-0.5, -0.6])); // out_lp_val (req0)
data.extend(i(&[10, 99])); // out_lp_idx
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut events = Vec::new();
assert!(
for_each_chunk(&framed[1..], |ev| events.push(ev)).ok,
@@ -1093,7 +1093,7 @@ mod tests {
data.extend(i(&[61])); // in_tid_idx
data.extend(f(&[7.1, 7.2, 7.3])); // hidden_val
let framed = frame_egress_batch_cols(&header, &[&data]);
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_eq!(events.len(), 1);
@@ -1137,7 +1137,7 @@ mod tests {
let mut header = Vec::new();
rmpv::encode::write_value(&mut header, &header_arr).unwrap();
let data: Vec<u8> = [7i32, 8].iter().flat_map(|x| x.to_le_bytes()).collect();
let framed = frame_egress_batch_cols(&header, &[&data]);
let framed = frame_decode_batch_cols(&header, &[&data]);
let mut events = Vec::new();
assert!(for_each_chunk(&framed[1..], |ev| events.push(ev)).ok);
// Each chunk carries its OWN rid — the value a shard keys its table on.
@@ -1147,13 +1147,7 @@ mod tests {
}
/// The common frame must stay small: logprob/hidden columns are boxed behind
/// `ChunkExtras`, so the inline decode array is a few KiB — not MiB — even at
/// batch 4096. A regression that inlines a rare column would blow this up.
///
/// The budget moved 128 → 144 when the rid gained its memoized partition key
/// (`String` + `u64`). That costs 8 bytes × batch per decode step and buys not
/// re-hashing the rid on every chunk in the egress bucketing loop — a
/// deliberate trade, not drift.
/// `ChunkExtras`.
#[test]
fn chunk_event_frame_stays_small() {
let sz = std::mem::size_of::<ChunkEvent>();
@@ -1182,7 +1176,7 @@ mod rid_recovery_tests {
cols.extend((0..extra_cols).map(|_| Value::from("unexpected")));
let mut header = Vec::new();
rmpv::encode::write_value(&mut header, &Value::Array(cols)).unwrap();
let framed = frame_egress_batch_cols(&header, &[]);
let framed = frame_decode_batch_cols(&header, &[]);
let decoded = for_each_chunk(&framed[1..], |_| {});
assert!(!decoded.ok, "arity {extra_cols}: must reject");
assert_eq!(
+3 -27
View File
@@ -2,29 +2,6 @@
//! (python/sglang/srt/sampling/sampling_params.py): every field, plus its
//! `__post_init__` → `normalize` → `verify` pipeline (run in that order, as
//! `TokenizerManager._create_tokenized_object` does).
//!
//! The embedded Rust server replaces the Python `TokenizerManager`, which is the
//! only place those three run on the normal (zmq) path. Running them here, in the
//! ingress `Normalizing` FSM step, keeps the per-request CPU (notably the
//! stop-string work) off the scheduler's latency-critical loop. We set
//! `is_normalized=true` on the wire so the scheduler's `__post_init__` and
//! `normalize` early-return; its `verify` is likewise skipped (we did it here).
//!
//! KEEP IN SYNC with `sampling_params.py`: the field list, defaults and ranges
//! below mirror that file, and the struct is serialized by field name into the
//! `TokenizedGenerateReqInput` header, so a renamed/added Python field must be
//! mirrored here (an unknown key would be silently dropped by msgspec).
//!
//! Two deliberate deviations, both safe over-estimates or stricter:
//! * `stop_str_max_len` is the stop string's **UTF-8 byte length** — a provably
//! safe over-estimate of its token length (a token spans ≥ 1 byte, so
//! `bytes ≥ tokens`; `chars` is *not* a bound — one char can be several
//! tokens, e.g. `𓀀` → 3). The scheduler uses it only as a match-window
//! *size* (capped at the output length), so an over-estimate matches the same
//! stops — only an under-estimate misses. Python encodes each stop with the
//! tokenizer for the exact count; the byte bound avoids needing it here.
//! * `n > 1` (parallel sampling) is rejected — the rust egress maps one rid to
//! one response, so every sample past the first would be dropped.
use std::collections::BTreeMap;
use std::fmt;
@@ -33,9 +10,8 @@ use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
use serde::de::{MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use super::OneOrMany;
use crate::error::Error;
use crate::utils::regex::RegexPattern;
use super::types::OneOrMany;
use crate::utils::{error::Error, regex::RegexPattern};
/// `_SAMPLING_EPS` — temperatures in `[0, eps)` mean greedy decoding.
const SAMPLING_EPS: f64 = 1e-6;
@@ -495,7 +471,7 @@ impl SamplingParams {
"Only one of regex, json_schema, or ebnf can be set".into()
));
}
// Not a Python restriction: the rust egress maps one rid to one response,
// Not a Python restriction: the rust from_scheduler maps one rid to one response,
// so parallel sampling would drop all but the first sample. This is the
// only place it is rejected — `n` lives in `sampling_params`, where
// Python reads it, and the `/generate` body has no `n` of its own.
-413
View File
@@ -1,413 +0,0 @@
//! Multimodal worker pool.
//!
//! Rust threads drain requests parked in `Encoding` and run the `sglang-mm`
//! pipeline registered by `Server.start_mm_workers` (decode → preprocess →
//! placeholder expansion → M-RoPE, GIL-free). Each worker parks the result
//! buffers in the rid-keyed [`Sidecar`] and returns only the expanded ids;
//! Python attaches the buffers at drain time (`Server.take_mm`). Inputs the
//! pipeline cannot serve are rejected to the client — no Python fallback.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::message::MmRequest;
use crate::runtime::Runnable;
use crate::tokenizer::TextTokenizer;
use crate::tokenizer_manager::TmEvent;
/// A named POSIX shared-memory segment owning its name: dropped → unlinked.
///
/// Written by an MM worker so the TP broadcast carries a ~100-byte
/// `ShmPointerMMData` stub instead of the ~20 MB feature tensor, and every
/// rank maps it in parallel. Python's `materialize()` unlinks after cloning;
/// this `Drop` covers the paths where the buffers never reach Python (aborted
/// while parked, late result purged).
pub struct ShmSegment {
name: String,
}
impl ShmSegment {
/// Create `/dev/shm/{name}` holding exactly `bytes`. No leading slash —
/// the name must suit Python's `SharedMemory(name=…)` (shm_open adds one).
pub fn create(name: String, bytes: &[u8]) -> Result<Self, String> {
let c_name = std::ffi::CString::new(format!("/{name}"))
.map_err(|_| "shm name contains NUL".to_string())?;
// SAFETY: plain POSIX calls on a name we own; every handle created
// below is closed/unmapped on all paths.
unsafe {
let fd = libc::shm_open(
c_name.as_ptr(),
libc::O_CREAT | libc::O_EXCL | libc::O_RDWR,
0o600,
);
if fd < 0 {
return Err(format!(
"shm_open({name}): {}",
std::io::Error::last_os_error()
));
}
let segment = Self { name }; // unlink from here on any failure
if libc::ftruncate(fd, bytes.len() as libc::off_t) != 0 {
let e = std::io::Error::last_os_error();
libc::close(fd);
return Err(format!("ftruncate({}): {e}", segment.name));
}
let ptr = libc::mmap(
std::ptr::null_mut(),
bytes.len(),
libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
libc::close(fd);
if ptr == libc::MAP_FAILED {
return Err(format!(
"mmap({}): {}",
segment.name,
std::io::Error::last_os_error()
));
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.cast::<u8>(), bytes.len());
libc::munmap(ptr, bytes.len());
Ok(segment)
}
}
/// Hand the segment — and the duty to unlink — to the caller (Python, at
/// drain time).
pub fn into_name(self) -> String {
std::mem::take(&mut std::mem::ManuallyDrop::new(self).name)
}
}
impl Drop for ShmSegment {
fn drop(&mut self) {
if let Ok(c_name) = std::ffi::CString::new(format!("/{}", self.name)) {
// SAFETY: unlinking a name we created; ENOENT (already unlinked
// by Python's materialize) is fine to ignore.
unsafe { libc::shm_unlink(c_name.as_ptr()) };
}
}
}
/// Unique segment names: the pid separates server restarts (a crash can leak
/// segments under the old pid), the counter separates results within one.
fn shm_name(item: usize) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("sglmm-{}-{n}-{item}", std::process::id())
}
/// Python parity: caller hashes override the computed ones so an external
/// router's keys align with the prefix cache. A length mismatch or malformed
/// entry warns and keeps the computed hash — never blocks the request.
fn apply_caller_hashes(hashes: &mut [u64], caller: &[String]) {
if caller.is_empty() {
return;
}
if caller.len() != hashes.len() {
tracing::warn!(
caller = caller.len(),
items = hashes.len(),
"mm_hashes length != mm item count; ignoring caller hashes"
);
return;
}
for (hash, entry) in hashes.iter_mut().zip(caller) {
match parse_caller_hash(entry) {
Some(v) => *hash = v,
None => tracing::warn!(%entry, "malformed mm_hashes entry; keeping computed hash"),
}
}
}
/// Hex of any width, as Python's `int(hex_hash, 16)` takes it (a full SHA-256
/// being the common case), keeping the low 64 bits — only the low 30 are
/// observable, through `_compute_pad_value`.
fn parse_caller_hash(entry: &str) -> Option<u64> {
let hex = entry.strip_prefix("0x").unwrap_or(entry);
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(&hex[hex.len().saturating_sub(16)..], 16).ok()
}
/// One parked result: the buffers the drain-time Python adapter needs (the
/// expanded `input_ids` travel separately, via `TmEvent::MmEncoded`). The qwen
/// drain shape (`sglang_mm::qwen_vl::pack_drain`); generalizes to a
/// named-tensor handoff once a family needs a different one.
pub struct MmSidecarEntry {
pub features: FeatureStore,
pub grids: Vec<[u32; 3]>,
pub hashes: Vec<u64>,
pub offsets: Vec<(u32, u32)>,
pub mrope: Vec<i64>,
pub mrope_delta: i64,
}
/// Where a result's feature buffers live between worker and drain.
pub enum FeatureStore {
/// In-process; the drain wraps them zero-copy. Single-rank serving, or the
/// shm fallback. Under TP the whole buffer would ride `broadcast_pyobj`.
Inline(Vec<f32>),
/// One POSIX segment per item, written by the worker; only the names cross
/// ranks. See [`ShmSegment`].
Shm(Vec<ShmSegment>),
}
/// Results parked between a worker's `MmEncoded` and the scheduler drain, keyed
/// by rid. Owns the lifecycle so entries never leak: [`park`](Self::park)
/// strictly before `MmEncoded`, [`take`](Self::take) at the drain,
/// [`purge`](Self::purge) for requests that die while parked.
#[derive(Clone, Default)]
pub struct Sidecar(Arc<Mutex<HashMap<String, MmSidecarEntry>>>);
impl Sidecar {
pub fn park(&self, rid: String, entry: MmSidecarEntry) {
self.0.lock().unwrap().insert(rid, entry);
}
pub fn take(&self, rid: &str) -> Option<MmSidecarEntry> {
self.0.lock().unwrap().remove(rid)
}
pub fn purge(&self, rid: &str) {
self.0.lock().unwrap().remove(rid);
}
}
/// Shared state of the mm path, built once at `start_mm_workers`.
pub struct Context {
pub family: Box<dyn sglang_mm::pipeline::MmFamilyProcessor>,
/// `None` under `skip_tokenizer_init` (requests must carry `input_ids`).
pub tokenizer: Option<Arc<dyn TextTokenizer>>,
pub sidecar: Sidecar,
/// Park feature buffers in POSIX shm. Set by the Python launcher
/// (`NativeMmHost._use_feature_shm`) exactly when the scheduler broadcasts
/// across TP ranks and will unwrap `ShmPointerMMData`.
pub feature_shm: bool,
}
impl Context {
pub fn new(
spec_json: &str,
tokenizer: Option<Arc<dyn TextTokenizer>>,
sidecar: Sidecar,
) -> Result<Self, String> {
let feature_shm = serde_json::from_str::<serde_json::Value>(spec_json)
.ok()
.and_then(|v| v.get("feature_shm").and_then(|b| b.as_bool()))
.unwrap_or(false);
Ok(Self {
family: sglang_mm::registry::pipeline_from_spec(spec_json)?,
tokenizer,
sidecar,
feature_shm,
})
}
}
/// Run the pipeline for one request. `Ok` returns the final expanded ids, the
/// buffers already parked; `Err` rejects the request back to the client.
fn process(
ctx: &Context,
rid: &crate::ids::Rid,
mut work: crate::message::MmWorkItem,
) -> Result<Vec<i32>, String> {
let caller_hashes = std::mem::take(&mut work.mm_hashes);
let input = crate::message::mm_payload::to_mm_input(work)?;
let output = sglang_mm::driver::process(ctx.family.as_ref(), input, |text| {
let tokenizer = ctx.tokenizer.as_ref().ok_or_else(|| {
"skip_tokenizer_init is set: multimodal text prompts require input_ids".to_string()
})?;
tokenizer.encode(text).map_err(|error| error.to_string())
})?;
let mut drain = sglang_mm::qwen_vl::pack_drain(output)?;
apply_caller_hashes(&mut drain.hashes, &caller_hashes);
let features = if ctx.feature_shm {
park_features_in_shm(&drain.features, &drain.grids)
} else {
FeatureStore::Inline(drain.features)
};
ctx.sidecar.park(
rid.as_str().to_owned(),
MmSidecarEntry {
features,
grids: drain.grids,
hashes: drain.hashes,
offsets: drain.offsets,
mrope: drain.mrope,
mrope_delta: drain.mrope_delta,
},
);
Ok(drain.input_ids)
}
/// Split the flat feature buffer per item (`t*h*w` rows per grid) and park each
/// slice in its own segment. Any shm failure (`/dev/shm` full, odd shape) falls
/// back to inline, as Python's `_wrap_shm_or_inline` does: degrade to the slow
/// path, never fail the request.
fn park_features_in_shm(features: &[f32], grids: &[[u32; 3]]) -> FeatureStore {
let total_rows: usize = grids
.iter()
.map(|g| g[0] as usize * g[1] as usize * g[2] as usize)
.sum();
if total_rows == 0 || !features.len().is_multiple_of(total_rows) {
return FeatureStore::Inline(features.to_vec());
}
let dim = features.len() / total_rows;
let mut segments = Vec::with_capacity(grids.len());
let mut row = 0usize;
for (item, grid) in grids.iter().enumerate() {
let rows = grid[0] as usize * grid[1] as usize * grid[2] as usize;
let slice = &features[row * dim..(row + rows) * dim];
row += rows;
match ShmSegment::create(shm_name(item), bytemuck::cast_slice(slice)) {
Ok(segment) => segments.push(segment),
Err(error) => {
tracing::warn!(%error, "mm: shm feature transport failed; falling back to inline");
return FeatureStore::Inline(features.to_vec());
}
}
}
FeatureStore::Shm(segments)
}
/// One MM worker, spawned via `Runtime::spawn_mm_pool` (which owns the
/// pinning policy for this pool — see its docs).
pub struct MmWorker {
rx: flume::Receiver<MmRequest>,
tm: flume::Sender<TmEvent>,
ctx: Arc<Context>,
}
impl MmWorker {
pub fn new(
rx: flume::Receiver<MmRequest>,
tm: flume::Sender<TmEvent>,
ctx: Arc<Context>,
) -> Self {
Self { rx, tm, ctx }
}
}
impl Runnable for MmWorker {
/// Drain until the mm channel closes (tm-ingress drops its sender on
/// shutdown). One request at a time, so the pool size bounds MM
/// concurrency; an error rejects the request back to the client.
fn run(self) {
while let Ok(req) = self.rx.recv() {
let rid = req.rid;
let event = match process(&self.ctx, &rid, req.work) {
Ok(input_ids) => {
tracing::debug!(%rid, tokens = input_ids.len(), "mm: processed");
TmEvent::MmEncoded { rid, input_ids }
}
Err(message) => {
tracing::warn!(%rid, %message, "mm processing rejected");
TmEvent::MmFailed { rid, message }
}
};
if self.tm.send(event).is_err() {
return; // tm-ingress gone: shutdown
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Caller hashes override computed ones; mismatched lengths and malformed
/// entries fall back per item, never reject (Python parity).
#[test]
fn caller_hashes_override_with_fallback() {
let mut hashes = vec![1, 2, 3];
apply_caller_hashes(&mut hashes, &[]);
assert_eq!(hashes, [1, 2, 3]);
apply_caller_hashes(&mut hashes, &["ff".into()]); // length mismatch
assert_eq!(hashes, [1, 2, 3]);
apply_caller_hashes(&mut hashes, &["ff".into(), "not-hex".into(), "0x10".into()]);
assert_eq!(hashes, [0xff, 2, 0x10]);
}
/// A full SHA-256 (what routers send) keeps its low 64 bits rather than
/// falling back, so the pad value matches Python's wide `int`.
#[test]
fn caller_hashes_accept_arbitrary_width() {
let sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let mut hashes = vec![1];
apply_caller_hashes(&mut hashes, &[sha256.into()]);
assert_eq!(hashes, [0xa495991b7852b855]);
assert_eq!(hashes[0] % (1 << 30), 944_945_237); // int(sha256, 16) % (1 << 30)
// Width alone is never malformed; a non-hex digit still is.
assert_eq!(parse_caller_hash(&"f".repeat(64)), Some(u64::MAX));
assert_eq!(parse_caller_hash("0x"), None);
assert_eq!(parse_caller_hash(""), None);
}
fn shm_path(name: &str) -> std::path::PathBuf {
std::path::Path::new("/dev/shm").join(name)
}
/// The segment holds exactly the written bytes and dropping it unlinks —
/// the leak guard for results purged before Python takes them.
#[test]
fn segment_roundtrip_and_drop_unlinks() {
let name = shm_name(0);
let payload: Vec<u8> = (0..255u8).collect();
let segment = ShmSegment::create(name.clone(), &payload).unwrap();
assert_eq!(std::fs::read(shm_path(&name)).unwrap(), payload);
drop(segment);
assert!(!shm_path(&name).exists(), "drop must unlink");
}
/// `into_name` transfers the unlink duty to the caller (Python's
/// `materialize()`), so the segment must survive the handoff.
#[test]
fn into_name_disarms_the_unlink() {
let segment = ShmSegment::create(shm_name(0), &[1, 2, 3]).unwrap();
let name = segment.into_name();
assert!(shm_path(&name).exists(), "handoff must not unlink");
// manual cleanup for the test
let c = std::ffi::CString::new(format!("/{name}")).unwrap();
unsafe { libc::shm_unlink(c.as_ptr()) };
}
/// Per-item slicing follows the grid row counts, so Python's
/// `(rows, feature_dim)` reshape of a segment sees only its own item.
#[test]
fn park_splits_features_by_grid() {
// Two items: grids (1,2,2)=4 rows and (1,1,2)=2 rows, dim=3.
let features: Vec<f32> = (0..18).map(|i| i as f32).collect();
let grids = [[1, 2, 2], [1, 1, 2]];
let FeatureStore::Shm(segments) = park_features_in_shm(&features, &grids) else {
panic!("expected shm store");
};
assert_eq!(segments.len(), 2);
let read = |seg: &ShmSegment| -> Vec<u8> { std::fs::read(shm_path(&seg.name)).unwrap() };
assert_eq!(
read(&segments[0]),
bytemuck::cast_slice::<f32, u8>(&features[..12])
);
assert_eq!(
read(&segments[1]),
bytemuck::cast_slice::<f32, u8>(&features[12..])
);
}
/// A degenerate shape must degrade to inline, never a shm-side panic.
#[test]
fn shape_surprise_falls_back_inline() {
let features = vec![0.0f32; 7]; // not divisible by 2 rows
let grids = [[1, 1, 2]];
assert!(matches!(
park_features_in_shm(&features, &grids),
FeatureStore::Inline(_)
));
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Multimodal worker pool.
pub mod payload;
mod shm;
pub mod sidecar;
pub mod worker;
@@ -9,7 +9,7 @@ use bytes::Bytes;
use rmpv::Value;
use sglang_mm::driver::{ImageSource, MmInput};
use super::request::MmWorkItem;
use crate::message::request::MmWorkItem;
/// True for sources the API layer must resolve before MM dispatch: I/O — network
/// *or* disk, since a network mount can hang past any HTTP timeout — never runs
@@ -114,8 +114,7 @@ fn collect_images(
}
/// Rust mirror of Python `has_valid_data`: `nil` and (recursively) empty or
/// all-nil lists don't count as multimodal input. Shared with the ingress
/// `has_multimodal` check so routing and parsing cannot drift.
/// all-nil lists don't count as multimodal input.
pub fn value_present(value: &Value) -> bool {
match value {
Value::Nil => false,
@@ -0,0 +1,122 @@
//! POSIX shared-memory transport for feature tensors.
use std::sync::atomic::{AtomicU64, Ordering};
/// A named POSIX shared-memory segment owning its name: dropped → unlinked.
///
/// Written by an MM worker so the TP broadcast carries a ~100-byte
/// `ShmPointerMMData` stub instead of the ~20 MB feature tensor, and every
/// rank maps it in parallel. Python's `materialize()` unlinks after cloning;
/// this `Drop` covers the paths where the buffers never reach Python (aborted
/// while parked, late result purged).
pub struct ShmSegment {
pub(super) name: String,
}
impl ShmSegment {
/// Create `/dev/shm/{name}` holding exactly `bytes`. No leading slash —
/// the name must suit Python's `SharedMemory(name=…)` (shm_open adds one).
pub fn create(name: String, bytes: &[u8]) -> Result<Self, String> {
let c_name = std::ffi::CString::new(format!("/{name}"))
.map_err(|_| "shm name contains NUL".to_string())?;
// SAFETY: plain POSIX calls on a name we own; every handle created
// below is closed/unmapped on all paths.
unsafe {
let fd = libc::shm_open(
c_name.as_ptr(),
libc::O_CREAT | libc::O_EXCL | libc::O_RDWR,
0o600,
);
if fd < 0 {
return Err(format!(
"shm_open({name}): {}",
std::io::Error::last_os_error()
));
}
let segment = Self { name }; // unlink from here on any failure
if libc::ftruncate(fd, bytes.len() as libc::off_t) != 0 {
let e = std::io::Error::last_os_error();
libc::close(fd);
return Err(format!("ftruncate({}): {e}", segment.name));
}
let ptr = libc::mmap(
std::ptr::null_mut(),
bytes.len(),
libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
libc::close(fd);
if ptr == libc::MAP_FAILED {
return Err(format!(
"mmap({}): {}",
segment.name,
std::io::Error::last_os_error()
));
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.cast::<u8>(), bytes.len());
libc::munmap(ptr, bytes.len());
Ok(segment)
}
}
/// Hand the segment — and the duty to unlink — to the caller (Python, at
/// drain time).
pub fn into_name(self) -> String {
std::mem::take(&mut std::mem::ManuallyDrop::new(self).name)
}
}
impl Drop for ShmSegment {
fn drop(&mut self) {
if let Ok(c_name) = std::ffi::CString::new(format!("/{}", self.name)) {
// SAFETY: unlinking a name we created; ENOENT (already unlinked
// by Python's materialize) is fine to ignore.
unsafe { libc::shm_unlink(c_name.as_ptr()) };
}
}
}
/// Unique segment names: the pid separates server restarts (a crash can leak
/// segments under the old pid), the counter separates results within one.
pub(super) fn shm_name(item: usize) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("sglmm-{}-{n}-{item}", std::process::id())
}
/// Test helper shared with the sidecar's parking tests.
#[cfg(test)]
pub(super) fn shm_path(name: &str) -> std::path::PathBuf {
std::path::Path::new("/dev/shm").join(name)
}
#[cfg(test)]
mod tests {
use super::*;
/// The segment holds exactly the written bytes and dropping it unlinks —
/// the leak guard for results purged before Python takes them.
#[test]
fn segment_roundtrip_and_drop_unlinks() {
let name = shm_name(0);
let payload: Vec<u8> = (0..255u8).collect();
let segment = ShmSegment::create(name.clone(), &payload).unwrap();
assert_eq!(std::fs::read(shm_path(&name)).unwrap(), payload);
drop(segment);
assert!(!shm_path(&name).exists(), "drop must unlink");
}
/// `into_name` transfers the unlink duty to the caller (Python's
/// `materialize()`), so the segment must survive the handoff.
#[test]
fn into_name_disarms_the_unlink() {
let segment = ShmSegment::create(shm_name(0), &[1, 2, 3]).unwrap();
let name = segment.into_name();
assert!(shm_path(&name).exists(), "handoff must not unlink");
// manual cleanup for the test
let c = std::ffi::CString::new(format!("/{name}")).unwrap();
unsafe { libc::shm_unlink(c.as_ptr()) };
}
}
@@ -0,0 +1,121 @@
//! Rid-keyed parking of finished results between an MM worker and the
//! scheduler drain.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use super::shm::{ShmSegment, shm_name};
/// One parked result: the buffers the drain-time Python adapter needs (the
/// expanded `input_ids` travel separately, via `TmEvent::MmEncoded`). The qwen
/// drain shape (`sglang_mm::qwen_vl::pack_drain`); generalizes to a
/// named-tensor handoff once a family needs a different one.
///
/// Constructed from outside the module only by tests; the worker parks every
/// real entry itself.
pub struct MmSidecarEntry {
pub features: FeatureStore,
pub grids: Vec<[u32; 3]>,
pub hashes: Vec<u64>,
pub offsets: Vec<(u32, u32)>,
pub mrope: Vec<i64>,
pub mrope_delta: i64,
}
/// Where a result's feature buffers live between worker and drain.
pub enum FeatureStore {
/// In-process; the drain wraps them zero-copy. Single-rank serving, or the
/// shm fallback. Under TP the whole buffer would ride `broadcast_pyobj`.
Inline(Vec<f32>),
/// One POSIX segment per item, written by the worker; only the names cross
/// ranks. See [`ShmSegment`].
Shm(Vec<ShmSegment>),
}
/// Results parked between a worker's `MmEncoded` and the scheduler drain, keyed
/// by rid. Owns the lifecycle so entries never leak: [`park`](Self::park)
/// strictly before `MmEncoded`, [`take`](Self::take) at the drain,
/// [`purge`](Self::purge) for requests that die while parked.
#[derive(Clone, Default)]
pub struct Sidecar(Arc<Mutex<HashMap<String, MmSidecarEntry>>>);
impl Sidecar {
pub fn park(&self, rid: String, entry: MmSidecarEntry) {
self.0.lock().unwrap().insert(rid, entry);
}
pub fn take(&self, rid: &str) -> Option<MmSidecarEntry> {
self.0.lock().unwrap().remove(rid)
}
pub fn purge(&self, rid: &str) {
self.0.lock().unwrap().remove(rid);
}
}
/// Split the flat feature buffer per item (`t*h*w` rows per grid) and park each
/// slice in its own segment. Any shm failure (`/dev/shm` full, odd shape) falls
/// back to inline, as Python's `_wrap_shm_or_inline` does: degrade to the slow
/// path, never fail the request.
pub(super) fn park_features_in_shm(features: &[f32], grids: &[[u32; 3]]) -> FeatureStore {
let total_rows: usize = grids
.iter()
.map(|g| g[0] as usize * g[1] as usize * g[2] as usize)
.sum();
if total_rows == 0 || !features.len().is_multiple_of(total_rows) {
return FeatureStore::Inline(features.to_vec());
}
let dim = features.len() / total_rows;
let mut segments = Vec::with_capacity(grids.len());
let mut row = 0usize;
for (item, grid) in grids.iter().enumerate() {
let rows = grid[0] as usize * grid[1] as usize * grid[2] as usize;
let slice = &features[row * dim..(row + rows) * dim];
row += rows;
match ShmSegment::create(shm_name(item), bytemuck::cast_slice(slice)) {
Ok(segment) => segments.push(segment),
Err(error) => {
tracing::warn!(%error, "mm: shm feature transport failed; falling back to inline");
return FeatureStore::Inline(features.to_vec());
}
}
}
FeatureStore::Shm(segments)
}
#[cfg(test)]
mod tests {
use super::super::shm::shm_path;
use super::*;
/// Per-item slicing follows the grid row counts, so Python's
/// `(rows, feature_dim)` reshape of a segment sees only its own item.
#[test]
fn park_splits_features_by_grid() {
// Two items: grids (1,2,2)=4 rows and (1,1,2)=2 rows, dim=3.
let features: Vec<f32> = (0..18).map(|i| i as f32).collect();
let grids = [[1, 2, 2], [1, 1, 2]];
let FeatureStore::Shm(segments) = park_features_in_shm(&features, &grids) else {
panic!("expected shm store");
};
assert_eq!(segments.len(), 2);
let read = |seg: &ShmSegment| -> Vec<u8> { std::fs::read(shm_path(&seg.name)).unwrap() };
assert_eq!(
read(&segments[0]),
bytemuck::cast_slice::<f32, u8>(&features[..12])
);
assert_eq!(
read(&segments[1]),
bytemuck::cast_slice::<f32, u8>(&features[12..])
);
}
/// A degenerate shape must degrade to inline, never a shm-side panic.
#[test]
fn shape_surprise_falls_back_inline() {
let features = vec![0.0f32; 7]; // not divisible by 2 rows
let grids = [[1, 1, 2]];
assert!(matches!(
park_features_in_shm(&features, &grids),
FeatureStore::Inline(_)
));
}
}
@@ -0,0 +1,187 @@
//! The worker pool: drain MM requests, run the `sglang-mm` pipeline, park
//! the result buffers.
use std::sync::Arc;
use super::sidecar::{FeatureStore, MmSidecarEntry, Sidecar, park_features_in_shm};
use crate::message::config::MmSpec;
use crate::message::ids::Rid;
use crate::message::request::MmRequest;
use crate::tokenizer_manager::tokenizer::TextTokenizer;
use crate::tokenizer_manager::wiring::TmEvent;
use crate::utils::runtime::Runnable;
/// Python parity: caller hashes override the computed ones so an external
/// router's keys align with the prefix cache. A length mismatch or malformed
/// entry warns and keeps the computed hash — never blocks the request.
fn apply_caller_hashes(hashes: &mut [u64], caller: &[String]) {
if caller.is_empty() {
return;
}
if caller.len() != hashes.len() {
tracing::warn!(
caller = caller.len(),
items = hashes.len(),
"mm_hashes length != mm item count; ignoring caller hashes"
);
return;
}
for (hash, entry) in hashes.iter_mut().zip(caller) {
match parse_caller_hash(entry) {
Some(v) => *hash = v,
None => tracing::warn!(%entry, "malformed mm_hashes entry; keeping computed hash"),
}
}
}
/// Hex of any width, as Python's `int(hex_hash, 16)` takes it (a full SHA-256
/// being the common case), keeping the low 64 bits — only the low 30 are
/// observable, through `_compute_pad_value`.
fn parse_caller_hash(entry: &str) -> Option<u64> {
let hex = entry.strip_prefix("0x").unwrap_or(entry);
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(&hex[hex.len().saturating_sub(16)..], 16).ok()
}
/// Shared state of the mm path, built once at `start_mm_workers`.
pub struct Context {
pub family: Box<dyn sglang_mm::pipeline::MmFamilyProcessor>,
/// `None` under `skip_tokenizer_init` (requests must carry `input_ids`).
pub tokenizer: Option<Arc<dyn TextTokenizer>>,
pub sidecar: Sidecar,
/// Park feature buffers in POSIX shm. Set by the Python launcher
/// (`NativeMmHost._use_feature_shm`) exactly when the scheduler broadcasts
/// across TP ranks and will unwrap `ShmPointerMMData`.
pub feature_shm: bool,
}
impl Context {
pub fn new(
spec: MmSpec,
tokenizer: Option<Arc<dyn TextTokenizer>>,
sidecar: Sidecar,
) -> Result<Self, String> {
Ok(Self {
family: sglang_mm::registry::build_pipeline(spec.pipeline)?,
tokenizer,
sidecar,
feature_shm: spec.feature_shm,
})
}
}
/// Run the pipeline for one request. `Ok` returns the final expanded ids, the
/// buffers already parked; `Err` rejects the request back to the client.
fn process(
ctx: &Context,
rid: &Rid,
mut work: crate::message::request::MmWorkItem,
) -> Result<Vec<i32>, String> {
let caller_hashes = std::mem::take(&mut work.mm_hashes);
let input = super::payload::to_mm_input(work)?;
let output = sglang_mm::driver::process(ctx.family.as_ref(), input, |text| {
let tokenizer = ctx.tokenizer.as_ref().ok_or_else(|| {
"skip_tokenizer_init is set: multimodal text prompts require input_ids".to_string()
})?;
tokenizer.encode(text).map_err(|error| error.to_string())
})?;
let mut drain = sglang_mm::qwen_vl::pack_drain(output)?;
apply_caller_hashes(&mut drain.hashes, &caller_hashes);
let features = if ctx.feature_shm {
park_features_in_shm(&drain.features, &drain.grids)
} else {
FeatureStore::Inline(drain.features)
};
ctx.sidecar.park(
rid.as_str().to_owned(),
MmSidecarEntry {
features,
grids: drain.grids,
hashes: drain.hashes,
offsets: drain.offsets,
mrope: drain.mrope,
mrope_delta: drain.mrope_delta,
},
);
Ok(drain.input_ids)
}
/// One MM worker, spawned via `Runtime::spawn_mm_pool` (which owns the
/// pinning policy for this pool — see its docs).
pub struct MmWorker {
rx: flume::Receiver<MmRequest>,
tm: flume::Sender<TmEvent>,
ctx: Arc<Context>,
}
impl MmWorker {
pub fn new(
rx: flume::Receiver<MmRequest>,
tm: flume::Sender<TmEvent>,
ctx: Arc<Context>,
) -> Self {
Self { rx, tm, ctx }
}
}
impl Runnable for MmWorker {
/// Drain until the mm channel closes (to-scheduler drops its sender on
/// shutdown). One request at a time, so the pool size bounds MM
/// concurrency; an error rejects the request back to the client.
fn run(self) {
while let Ok(req) = self.rx.recv() {
let rid = req.rid;
let event = match process(&self.ctx, &rid, req.work) {
Ok(input_ids) => {
tracing::debug!(%rid, tokens = input_ids.len(), "mm: processed");
TmEvent::MmEncoded { rid, input_ids }
}
Err(message) => {
tracing::warn!(%rid, %message, "mm processing rejected");
TmEvent::MmFailed { rid, message }
}
};
if self.tm.send(event).is_err() {
return; // to-scheduler gone: shutdown
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Caller hashes override computed ones; mismatched lengths and malformed
/// entries fall back per item, never reject (Python parity).
#[test]
fn caller_hashes_override_with_fallback() {
let mut hashes = vec![1, 2, 3];
apply_caller_hashes(&mut hashes, &[]);
assert_eq!(hashes, [1, 2, 3]);
apply_caller_hashes(&mut hashes, &["ff".into()]); // length mismatch
assert_eq!(hashes, [1, 2, 3]);
apply_caller_hashes(&mut hashes, &["ff".into(), "not-hex".into(), "0x10".into()]);
assert_eq!(hashes, [0xff, 2, 0x10]);
}
/// A full SHA-256 (what routers send) keeps its low 64 bits rather than
/// falling back, so the pad value matches Python's wide `int`.
#[test]
fn caller_hashes_accept_arbitrary_width() {
let sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let mut hashes = vec![1];
apply_caller_hashes(&mut hashes, &[sha256.into()]);
assert_eq!(hashes, [0xa495991b7852b855]);
assert_eq!(hashes[0] % (1 << 30), 944_945_237); // int(sha256, 16) % (1 << 30)
// Width alone is never malformed; a non-hex digit still is.
assert_eq!(parse_caller_hash(&"f".repeat(64)), Some(u64::MAX));
assert_eq!(parse_caller_hash("0x"), None);
assert_eq!(parse_caller_hash(""), None);
}
}
-312
View File
@@ -1,312 +0,0 @@
//! Runtime configuration: the rust-server boot knobs
//! ([`RustServerServerArgs`]), the typed view of the scheduler's `server_args`
//! dump ([`ServerArgs`] / [`ModelConfig`]), and the [`RuntimeConfig`] pairing
//! them for `runtime::start`.
use std::net::SocketAddr;
use std::sync::Arc;
/// Boot knobs specific to the embedded rust server — none of these exist in
/// the Python `server_args` dump (see [`ServerArgs`]); they arrive as explicit
/// `Server::start` parameters.
#[derive(Clone, Debug)]
pub struct RustServerServerArgs {
pub http_addr: SocketAddr,
pub api_worker_num: usize,
pub ingress_ring_cap: usize,
pub egress_ring_cap: usize,
pub channel_cap: usize,
/// CPU core ids the pools pin to (e.g. this rank's NUMA-local cores minus
/// the scheduler's reserved launch cores). `None` → run unpinned.
pub cores: Option<Vec<usize>>,
}
impl Default for RustServerServerArgs {
fn default() -> Self {
Self {
http_addr: "127.0.0.1:30000".parse().unwrap(),
api_worker_num: 2,
ingress_ring_cap: 8192,
egress_ring_cap: 8192,
channel_cap: 8192,
cores: None,
}
}
}
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
/// Rust-server-only boot knobs (listen address, pool/ring sizes, pinning).
pub rust_server_args: RustServerServerArgs,
/// The scheduler's `server_args` dump (worker counts, tokenizer source,
/// config-endpoint metadata). `Arc` so cloning the config (and, downstream,
/// each `AppState`) is cheap; immutable after construction.
pub server_args: Arc<ServerArgs>,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
rust_server_args: RustServerServerArgs::default(),
server_args: Arc::new(
ServerArgs::from_json("{}").expect("empty server_args blob parses"),
),
}
}
}
/// The scheduler's startup blob (`RustServer._build_server_args`) parsed once into
/// typed fields: values are post-`__post_init__`; unrelated unknown keys are dropped.
#[derive(Debug, serde::Deserialize)]
pub struct ServerArgs {
/// HF repo id / local dir of the model, reported by `/get_model_info`.
#[serde(default)]
pub model_path: String,
/// Model name reported by `/v1/models` and `/server_info`.
#[serde(default)]
pub served_model_name: String,
/// Tokenizer source (model dir / `tokenizer.json` / HF repo id). Empty only
/// in minimal standalone blobs — then boot requires `skip_tokenizer_init`.
#[serde(default)]
pub tokenizer_path: String,
/// HF revision, used only when `tokenizer_path` is a repo id. `None` → main.
#[serde(default)]
pub revision: Option<String>,
/// Weight format selected by `--load-format`, reported by `/get_model_info`.
/// The blob carries the post-`__post_init__` value (`auto` is already
/// narrowed to `gguf` / `mistral` / `runai_streamer` / `remote` where the
/// checkpoint demands it). Not consumed for loading -- the scheduler owns
/// that; `None` only when the blob omits the key.
#[serde(default)]
pub load_format: Option<String>,
/// Operator-supplied weight version, reported by `/model_info`. Defaults to
/// `"default"` on the Python side, so it is present in every blob; `None`
/// only when the blob omits the key.
#[serde(default)]
pub weight_version: Option<String>,
/// HTTP bind address (see [`Self::bind`]).
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
/// Log levels driving the access log — uvicorn runs at
/// `log_level_http or log_level` (see [`Self::http_access_log_enabled`]).
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub log_level_http: Option<String>,
/// Optional built-in chat-template name or path to a Jinja/legacy JSON
/// template file. Without an override, uses the tokenizer config template.
#[serde(default)]
pub chat_template: Option<String>,
/// Parser selected by `--tool-call-parser`.
#[serde(default)]
pub tool_call_parser: Option<String>,
/// Reasoning splitter selected by `--reasoning-parser` (e.g. deepseek-r1).
/// When set, chat completions strip the model's reasoning markers out of
/// `content` into `reasoning_content` — both unary and streaming.
#[serde(default)]
pub reasoning_parser: Option<String>,
/// Python's global default for whether an SSE stream ends with a usage chunk.
#[serde(default)]
pub stream_response_default_include_usage: bool,
/// Pinned tokenizer threads / detok shards (Python asserts both ≥ 1).
#[serde(default = "default_worker_num")]
pub tokenizer_worker_num: usize,
#[serde(default = "default_worker_num")]
pub detokenizer_worker_num: usize,
/// Token-ids-in / token-ids-out mode: no tokenizer load, raw `output_ids`
/// frames (drives the `Skip` detok backend and the ingress branch).
#[serde(default)]
pub skip_tokenizer_init: bool,
/// Streamed `/generate` frames carry per-step deltas instead of cumulative
/// text. Matches the Python `TokenizerManager`.
#[serde(default)]
pub incremental_streaming_output: bool,
/// PD-disaggregation role: `"null"` (unified), `"prefill"`, or `"decode"`.
/// (On prefill, the KV bootstrap registry is mounted on the api router —
/// see [`Self::enable_pd_bootstrap`].)
#[serde(default = "default_disaggregation_mode")]
pub disaggregation_mode: String,
/// The resolved Python `ModelConfig`, attached to the blob at dump time.
#[serde(default)]
pub model_config: ModelConfig,
/// Default sampling params advertised by `/get_model_info`, verbatim from
/// `server_args.preferred_sampling_params` (a JSON object or null).
#[serde(default)]
pub preferred_sampling_params: Option<serde_json::Value>,
/// Over-long inputs are truncated to fit the context instead of 400ing, and
/// `max_new_tokens` is clamped rather than rejected (Python
/// `TokenizerManager._validate_one_request`).
#[serde(default)]
pub allow_auto_truncate: bool,
/// `return_hidden_states` is refused unless the server was launched with it:
/// the scheduler simply won't produce them, so the request would 200 with the
/// field silently missing.
#[serde(default)]
pub enable_return_hidden_states: bool,
/// Output slots reserved per request on top of its input (eagle stores draft
/// tokens there). Not a `server_args` field — `TokenizerManager` derives it and
/// `RustServer._build_server_args` stamps it in, so both sides count alike.
#[serde(default)]
pub num_reserved_tokens: u64,
/// Launch-time stamps (not `server_args` fields): sglang package version
/// and the scheduler-derived KV token capacity, reported by `/server_info`.
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub max_total_num_tokens: Option<u64>,
}
/// The slice of the resolved Python `ModelConfig` the rust server reads.
#[derive(Debug, Default, serde::Deserialize)]
pub struct ModelConfig {
/// Resolved context length (`max_model_len` in `/v1/models`); mandatory at
/// boot ([`ServerArgs::validate_mandatory`]).
#[serde(default)]
pub context_len: Option<u64>,
/// Bounds client-supplied token ids — ingress 400s out-of-vocab ids before
/// they crash the scheduler's embedding lookup; mandatory at
/// boot ([`ServerArgs::validate_mandatory`]).
#[serde(default)]
pub vocab_size: Option<u64>,
/// Whether the model accepts multimodal inputs. Gates the MM Encoding branch
/// in tm-ingress; `false` silently ignores mm fields, as the Python
/// `TokenizerManager` does with `mm_processor is None`.
#[serde(default)]
pub is_multimodal: bool,
/// Resolved default sampling parameters, stamped by
/// `RustServer._build_server_args` from Python's
/// `ModelConfig.get_default_sampling_params()`. Already gated on
/// `--sampling-defaults`: holds the model's generation_config.json values
/// in "model" mode, and is empty in "openai" mode. Consumed when a chat
/// request omits `temperature`/`top_p` — the conversion must not skip
/// straight to the OpenAI terminal defaults.
#[serde(default)]
pub default_sampling_params: DefaultSamplingParams,
}
/// One `SamplingParams` field per key `get_default_sampling_params()` may emit
/// (`repetition_penalty`, `temperature`, `top_k`, `top_p`, `min_p`), filtered
/// to values the generation config actually sets — hence all `Option`.
///
/// `top_k` / `min_p` / `repetition_penalty` are parsed for parity with the
/// Python dict but not yet consumed: the Dynamo chat request type only carries
/// `temperature` and `top_p`, so the conversion resolves just those two.
#[derive(Debug, Default, serde::Deserialize)]
#[allow(dead_code)]
pub struct DefaultSamplingParams {
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default)]
pub top_p: Option<f64>,
#[serde(default)]
pub top_k: Option<i64>,
#[serde(default)]
pub min_p: Option<f64>,
#[serde(default)]
pub repetition_penalty: Option<f64>,
}
fn join_host_port(host: &str, port: u16) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}") // bare IPv6 (`::`) needs brackets to bind
} else {
format!("{host}:{port}")
}
}
fn default_host() -> String {
"127.0.0.1".into()
}
fn default_port() -> u16 {
30000
}
fn default_log_level() -> String {
"info".into()
}
fn default_disaggregation_mode() -> String {
"null".into()
}
fn default_worker_num() -> usize {
1
}
impl ServerArgs {
/// Parse the blob; errors on malformed JSON or a wrongly-typed field.
pub fn from_json(s: &str) -> Result<Self, String> {
serde_json::from_str(s).map_err(|e| e.to_string())
}
/// Fail fast at startup if a field an endpoint depends on is missing.
pub fn validate_mandatory(&self) -> Result<(), String> {
if self.served_model_name.is_empty() {
return Err("no 'served_model_name' in server_args".into());
}
if self.model_config.context_len.is_none() {
return Err("no resolvable context length (model_config.context_len)".into());
}
if self.model_config.vocab_size.is_none() {
return Err("no resolvable vocab size (model_config.vocab_size)".into());
}
if !matches!(
self.disaggregation_mode.as_str(),
"null" | "prefill" | "decode"
) {
return Err(format!(
"unknown disaggregation_mode '{}' in server_args",
self.disaggregation_mode
));
}
Ok(())
}
/// True on a prefill or decode node — requests need bootstrap routing.
pub fn is_disaggregation(&self) -> bool {
self.disaggregation_mode != "null"
}
/// Serve the PD KV bootstrap registry on the api listener: every prefill
/// rust server hosts it, unconditionally — no extra topology gating. KV
/// managers and decode nodes reach the registry at the resolved
/// `disaggregation_bootstrap_port`, which rust-server mode aliases to the
/// api port, so whichever prefill server that port names is the one that
/// receives the registrations.
pub fn enable_pd_bootstrap(&self) -> bool {
self.disaggregation_mode == "prefill"
}
/// Whether the served model is multimodal, from the scheduler's dump. See
/// [`ModelConfig::is_multimodal`].
pub fn model_is_multimodal(&self) -> bool {
self.model_config.is_multimodal
}
/// Bind address `host:port`. `host` is expected to be an IP — the result is
/// parsed as a `SocketAddr`, so a bare IPv6 host gets bracketed.
pub fn bind(&self) -> String {
join_host_port(&self.host, self.port)
}
/// Whether the HTTP access log is emitted, mirroring the Python server:
/// uvicorn runs at `log_level_http or log_level` and prints access lines
/// only at info/debug. `--log-level-http warning` turns them off.
pub fn http_access_log_enabled(&self) -> bool {
let level = self
.log_level_http
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&self.log_level);
matches!(
level.to_ascii_lowercase().as_str(),
"trace" | "debug" | "info"
)
}
/// Pinned API threads for the embedded HTTP api-server. Python `server_args`
/// has no such field — this is derived: enough to cover the widest pool.
pub fn api_worker_num(&self) -> usize {
4.max(self.tokenizer_worker_num)
.max(self.detokenizer_worker_num)
}
}
@@ -1,10 +0,0 @@
//! The [`Runnable`] stage trait — the one contract every pipeline stage
//! (CPU-bound worker or TM router) implements to be spawned by the runtime.
/// A pipeline stage that owns its channel handles + config and runs a blocking
/// loop until its inbox closes. Lets the runtime spawn stages uniformly via
/// `threads::spawn_stage` / `threads::spawn_pool` instead of free `run_*` functions with
/// positional handles. Implemented by every CPU-bound worker and TM router.
pub trait Runnable: Send + 'static {
fn run(self);
}
+7 -99
View File
@@ -1,100 +1,8 @@
//! TokenizerManager — owns the request lifecycle across two isolated threads:
//!
//! * [`ingress`] — drives the ingress FSM (Received → Validating →
//! Normalizing → {Tokenizing | PreSendValidating}) and pushes tokenized requests to the
//! scheduler ring.
//! * [`egress`] — drains the scheduler-output ring and routes each chunk to
//! the owning detokenizer shard.
//!
//! The two run on separate pinned threads with no shared state, connected to
//! the rest of the pipeline only through `flume` channels: [`TmEvent`] into the
//! ingress loop, [`Senders`] fanning out to the pools.
//! TokenizerManager
mod egress;
mod ingress;
pub use egress::{ActivityCounter, Egress};
pub use ingress::{Ingress, Limits, Mm};
use crate::ids::Rid;
use crate::message::{DetokMsg, Request};
/// Blocking receive that also wakes on shutdown: returns `None` when `rx` closes
/// *or* the `shutdown` sender is dropped.
pub fn recv<T>(rx: &flume::Receiver<T>, shutdown: &flume::Receiver<()>) -> Option<T> {
flume::Selector::new()
.recv(rx, |r| r.ok())
.recv(shutdown, |_| None)
.wait()
}
/// Events into the TokenizerManager ingress loop. API server + tokenizer pool
/// share this one inbox, keeping the loop a single consumer (no `select`).
pub enum TmEvent {
/// A freshly received request from the API server.
Ingress(Request),
/// A request back from the tokenizer pool: `PreSendValidating` (ids filled) on success,
/// or `Failed` on a tokenize error. `drive` handles both.
Tokenized(Request),
/// An MM worker finished a request parked in `Encoding`: `input_ids` are the
/// final placeholder-expanded prompt ids. The buffers ride the rid-keyed
/// sidecar (`Server.take_mm`), not this event.
MmEncoded { rid: Rid, input_ids: Vec<i32> },
/// An MM worker rejected a request parked in `Encoding` (bad media URL,
/// unsupported modality, preprocess error, …).
MmFailed { rid: Rid, message: String },
}
/// Producer-side handles, cloned into every stage that needs to emit.
/// Who asked for an abort. Both variants do the same work in
/// [`Ingress::on_abort`](crate::tokenizer_manager::ingress::Ingress) — deregister
/// the detok entry, tell the scheduler to stop — and the source is kept for
/// diagnostics.
///
/// There is no in-flight rid registry to keep consistent, and so no release
/// ordering to get wrong: [`Rid::from_client`] makes every client-supplied rid
/// internally unique, so a resubmit of the "same" rid is a different `Rid` and
/// cannot be tangled up with an abort still in flight for the original.
#[derive(Clone, Debug)]
pub enum AbortSource {
/// From an `AbortGuard` drop. Owns the release.
Guard(Rid),
/// From a detokenizer terminal path. Aborts the scheduler work.
Detok(Rid),
}
impl AbortSource {
pub fn rid(&self) -> &Rid {
match self {
Self::Guard(rid) | Self::Detok(rid) => rid,
}
}
}
#[derive(Clone)]
pub struct Senders {
/// → TokenizerManager ingress loop.
pub tm: flume::Sender<TmEvent>,
/// → the same loop, but UNBOUNDED and abort-only.
///
/// Aborts cannot share the bounded inbox. `try_send` there drops them exactly
/// when they matter most — under overload — leaving the scheduler generating
/// for a dead connection; and the caller then faces a false choice between
/// releasing the rid (a live entry can be overwritten by a resubmit) and
/// holding it (a permanent leak). An unbounded lane removes the dilemma: an
/// abort is a small `String` and is always accepted, so releases can be
/// unconditional again. It cannot grow without bound in practice — one entry
/// per in-flight request, each already bounded by the inbox that admitted it.
pub abort: flume::Sender<AbortSource>,
/// → Tokenizer pool (CPU-bound, pinned threads).
pub tok: flume::Sender<Request>,
/// → Detokenizer shards, indexed by `Rid::shard(detok.len())`.
pub detok: Vec<flume::Sender<DetokMsg>>,
}
impl Senders {
#[inline]
pub fn detok_for(&self, rid: &Rid) -> &flume::Sender<DetokMsg> {
&self.detok[rid.shard(self.detok.len())]
}
}
pub mod channel;
pub mod detokenizer;
pub mod from_scheduler;
pub mod to_scheduler;
pub mod tokenizer;
pub mod wiring;
@@ -4,47 +4,42 @@
//! share one process, so these are in-process `flume` channels — literal
//! `mpsc`/`mpmc`, no shared memory, no serialization beyond the msgpack bytes
//! the payload already is.
//!
//! GIL note: the Python side only ever calls the *non-blocking* `drain` /
//! `try_push` methods while holding the GIL, and the Rust worker threads only
//! ever push/drain raw `Bytes` — neither side touches a `PyObject` off-thread,
//! so the producer threads never need the GIL.
use std::sync::Mutex;
use std::time::Duration;
use bytes::Bytes;
use crate::message::IngressMsg;
use crate::message::request::SchedulerRequest;
/// Ingress: TokenizerManager → scheduler `recv_requests`.
/// ToSchedulerTx: TokenizerManager → scheduler `recv_requests`.
/// Producers are Rust TM workers; the single consumer is the Python thread.
/// Carries [`IngressMsg`] (columnar: scalar header + raw int64 ids cell), not a
/// Carries [`SchedulerRequest`] (columnar: scalar header + raw int64 ids cell), not a
/// single msgpack blob, so the large `input_ids` tensor bypasses msgpack.
#[derive(Clone)]
pub struct IngressProducer {
tx: flume::Sender<IngressMsg>,
pub struct ToSchedulerTx {
tx: flume::Sender<SchedulerRequest>,
}
pub struct IngressConsumer {
rx: flume::Receiver<IngressMsg>,
pub struct ToSchedulerRx {
rx: flume::Receiver<SchedulerRequest>,
/// One-slot buffer holding a message consumed by a blocking [`wait`] so the
/// scheduler can park on idle without losing it — the next [`drain`] returns
/// it first. Only ever touched by the single consumer (the Python thread),
/// so contention is nil; the `Mutex` is just for interior mutability across
/// the `&self` methods.
///
/// [`wait`]: IngressConsumer::wait
/// [`drain`]: IngressConsumer::drain
stash: Mutex<Option<IngressMsg>>,
/// [`wait`]: ToSchedulerRx::wait
/// [`drain`]: ToSchedulerRx::drain
stash: Mutex<Option<SchedulerRequest>>,
}
/// A drained ingress batch in **columnar** (struct-of-arrays) form. The `ids`
/// A drained request batch in **columnar** (struct-of-arrays) form. The `ids`
/// cells are kept *un-concatenated* so the pyo3 boundary can copy them straight
/// into one `PyBytes` (no intermediate buffer); `ids_total` is their summed
/// length, precomputed for that single allocation.
#[derive(Default)]
pub struct IngressColumns {
pub struct RequestColumns {
/// Per-request scalar msgpack header (`input_ids` omitted).
pub headers: Vec<Bytes>,
/// Per-request raw little-endian int64 ids cell (empty for control reqs).
@@ -55,26 +50,39 @@ pub struct IngressColumns {
pub ids_total: usize,
}
impl IngressProducer {
impl RequestColumns {
/// Concatenate the `ids` cells into `buf`, which must be exactly
/// `ids_total` bytes — the pyo3 boundary hands in the freshly allocated
/// `PyBytes` so the ids are copied once, straight to their destination.
pub fn copy_ids_into(&self, mut buf: &mut [u8]) {
debug_assert_eq!(buf.len(), self.ids_total);
for cell in &self.ids {
let (dst, rest) = buf.split_at_mut(cell.len());
dst.copy_from_slice(cell);
buf = rest;
}
}
}
impl ToSchedulerTx {
/// Non-blocking push. Returns `false` on a full ring (backpressure) so the
/// caller can fail the request rather than block a worker thread.
#[inline]
pub fn try_push(&self, msg: IngressMsg) -> bool {
pub fn try_push(&self, msg: SchedulerRequest) -> bool {
self.tx.try_send(msg).is_ok()
}
}
impl IngressConsumer {
/// Drain up to `max` messages into a columnar [`IngressColumns`], returning
impl ToSchedulerRx {
/// Drain up to `max` messages into a columnar [`RequestColumns`], returning
/// immediately when the ring runs dry — mirrors the scheduler's existing
/// `zmq.NOBLOCK` loop in `request_receiver._pull_raw_reqs`. Splitting headers
/// from ids here (off the GIL) leaves `recv_requests` a thin marshaling shim.
/// `zmq.NOBLOCK` loop in `request_receiver._pull_raw_reqs`.
///
/// Non-blocking by construction: `try_recv` returns `Err(TryRecvError::Empty)`
/// instantly when the ring is empty, and `Err(_) => break` exits the loop
/// right away.
pub fn drain(&self, max: usize) -> IngressColumns {
let mut batch = IngressColumns::default();
pub fn drain(&self, max: usize) -> RequestColumns {
let mut batch = RequestColumns::default();
// A message parked by a prior blocking `wait` is delivered first.
if let Some(m) = self.stash.lock().unwrap().take() {
push_msg(&mut batch, m);
@@ -110,44 +118,32 @@ impl IngressConsumer {
/// Append one drained message's columnar cells to the batch.
#[inline]
fn push_msg(batch: &mut IngressColumns, m: IngressMsg) {
fn push_msg(batch: &mut RequestColumns, m: SchedulerRequest) {
batch.ids_total += m.ids.len();
batch.lengths.push((m.ids.len() / 8) as u32); // int64 cell → tokens
batch.headers.push(m.header);
batch.ids.push(m.ids);
}
/// Egress: scheduler output (`push_chunk`) → Rust egress dispatcher.
/// The single producer is the Python thread; the consumer is the dispatcher.
/// Scheduler output (`Server.push_decode_result_batch` / `push_control_result`
/// / `push_error`) → Rust response dispatcher. The single producer is the
/// Python thread; the consumer is the dispatcher.
#[derive(Clone)]
pub struct EgressProducer {
pub struct FromSchedulerTx {
tx: flume::Sender<Bytes>,
}
pub struct EgressConsumer {
pub struct FromSchedulerRx {
rx: flume::Receiver<Bytes>,
}
impl EgressProducer {
/// Blocking push: parks until the ring has space, so a full ring applies
/// backpressure to the scheduler instead of dropping output the scheduler has
/// already committed (advanced `send_token_offset` for). The GIL is released
/// around the call, so parking here doesn't stall other Python threads.
/// `false` only when the consumer is gone (runtime shutdown), where the frame
/// is unavoidably lost.
impl FromSchedulerTx {
/// Blocking push.
pub fn push(&self, msg: Bytes) -> bool {
self.tx.send(msg).is_ok()
}
/// Non-blocking push, so the pyo3 boundary can try to hand the frame over
/// while still holding the GIL and detach only when it would actually park.
/// Releasing the GIL is not free: reacquiring it waits out the interpreter's
/// switch interval (5 ms by default), which dwarfs the sub-microsecond push
/// it was protecting.
///
/// Hands the frame BACK on a full ring (`Err(Some(msg))`) so the caller can
/// retry it under [`push`](Self::push) without rebuilding it. `Err(None)` is
/// the consumer being gone (shutdown), where the frame is unavoidably lost.
/// Non-blocking push.
#[inline]
pub fn try_push(&self, msg: Bytes) -> Result<(), Option<Bytes>> {
match self.tx.try_send(msg) {
@@ -158,37 +154,37 @@ impl EgressProducer {
}
}
impl EgressConsumer {
impl FromSchedulerRx {
/// The underlying receiver, so the dispatcher can drain it via
/// [`tokenizer_manager::recv`](crate::tokenizer_manager::recv) (data + shutdown select).
/// [`wiring::recv`](crate::tokenizer_manager::wiring::recv) (data + shutdown select).
pub fn receiver(&self) -> &flume::Receiver<Bytes> {
&self.rx
}
}
/// Build both halves of a bounded ring.
pub fn ingress_ring(cap: usize) -> (IngressProducer, IngressConsumer) {
pub fn to_scheduler(cap: usize) -> (ToSchedulerTx, ToSchedulerRx) {
let (tx, rx) = flume::bounded(cap);
(
IngressProducer { tx },
IngressConsumer {
ToSchedulerTx { tx },
ToSchedulerRx {
rx,
stash: Mutex::new(None),
},
)
}
pub fn egress_ring(cap: usize) -> (EgressProducer, EgressConsumer) {
pub fn from_scheduler(cap: usize) -> (FromSchedulerTx, FromSchedulerRx) {
let (tx, rx) = flume::bounded(cap);
(EgressProducer { tx }, EgressConsumer { rx })
(FromSchedulerTx { tx }, FromSchedulerRx { rx })
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(h: &'static [u8]) -> IngressMsg {
IngressMsg {
fn msg(h: &'static [u8]) -> SchedulerRequest {
SchedulerRequest {
header: Bytes::from_static(h),
ids: Bytes::new(),
}
@@ -198,7 +194,7 @@ mod tests {
/// non-destructively, and the next `drain` returns it.
#[test]
fn wait_stashes_then_drain_returns_it() {
let (tx, rx) = ingress_ring(8);
let (tx, rx) = to_scheduler(8);
// Empty ring → times out, nothing stashed.
assert!(!rx.wait(Duration::from_millis(1)));
// Push one, then wait stashes it (returns true).
@@ -214,7 +210,7 @@ mod tests {
/// A blocked `wait` is woken the instant a producer pushes (no polling).
#[test]
fn wait_wakes_on_push() {
let (tx, rx) = ingress_ring(8);
let (tx, rx) = to_scheduler(8);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
let _ = tx.try_push(msg(b"a"));
@@ -225,11 +221,11 @@ mod tests {
assert_eq!(rx.drain(16).headers.len(), 1);
}
/// A full egress ring parks the producer until the consumer drains — the
/// A full from_scheduler channel parks the producer until the consumer drains — the
/// committed frame is delivered in order, never dropped.
#[test]
fn egress_push_blocks_until_drained() {
let (tx, rx) = egress_ring(1);
fn response_push_blocks_until_drained() {
let (tx, rx) = from_scheduler(1);
assert!(tx.push(Bytes::from_static(b"a"))); // fits; ring now full
let t = std::thread::spawn(move || tx.push(Bytes::from_static(b"b")));
// The parked push can't have completed while the ring is full.
@@ -243,8 +239,8 @@ mod tests {
/// A closed ring (consumer gone → shutdown) returns `false` instead of
/// parking forever, so a scheduler blocked in `push` unblocks on teardown.
#[test]
fn egress_push_returns_false_when_closed() {
let (tx, rx) = egress_ring(1);
fn response_push_returns_false_when_closed() {
let (tx, rx) = from_scheduler(1);
drop(rx);
assert!(!tx.push(Bytes::from_static(b"x")));
}
@@ -17,22 +17,26 @@
//! `skip_tokenizer_init` is set) the backend is `Skip`: no decoding, the raw
//! `output_ids` are emitted instead of text.
//!
//! Per-chunk egress flow (no FSM state change inside Streaming):
//! Per-chunk response flow (no FSM state change inside Streaming):
//! ChunkEvent{finish:None} -> step ids -> delta -> Server frame
//! ChunkEvent{finish:Some} -> step ids -> delta -> final frame
use std::collections::HashMap;
use crate::error::Error;
use crate::fsm::{Event, RequestState};
use crate::ids::Rid;
use crate::message::DetokMsg;
use crate::message::{ChunkEvent, EgressItem, EgressSink, Matched, SinkError, TokenIds};
use crate::runtime::Runnable;
use crate::tokenizer_manager::AbortSource;
use crate::message::detok::DetokMsg;
use crate::message::finish_reason::Matched;
use crate::message::ids::Rid;
use crate::message::response::{ChunkEvent, ResponseItem, ResponseSink, SinkError};
use crate::message::types::TokenIds;
use crate::tokenizer_manager::wiring::AbortSource;
use crate::utils::runtime::Runnable;
use crate::utils::{
error::Error,
fsm::{Event, RequestState},
};
/// Default for `skip_special_tokens` (SGLang's SamplingParams default). The
/// per-request value isn't available on the egress side yet; see the note in
/// per-request value isn't available on the response yet; see the note in
/// `DetokenizerBackend::new_decoder`.
const SKIP_SPECIAL_TOKENS: bool = true;
@@ -126,7 +130,7 @@ impl DetokenizerBackend {
}
struct DetokState {
sink: EgressSink,
sink: ResponseSink,
/// `return_text_in_logprobs`: whether to decode this request's logprob token
/// ids to text (in this shard) for the `[logprob, token_id, text]` tuples.
decode_logprob_text: bool,
@@ -140,15 +144,14 @@ struct DetokState {
/// cumulative view where a consumer needs it (every unary response and the
/// cumulative SGLang `/generate` stream); OpenAI streaming forwards deltas.
decoder: Option<Box<dyn StreamDecoder>>,
/// Egress half of the lifecycle FSM. Lives here because the ingress
/// `Request` (and its FSM) was handed to the scheduler when queued; the
/// shard is the sole owner of the request's egress state, so no lock.
/// Response half of the lifecycle FSM. Lives here because the `Request` (and
/// its FSM) was handed to the scheduler when queued; the shard is the sole
/// owner of the response state, so no lock.
fsm: RequestState,
}
/// One detokenizer shard: owns a *local* `rid -> DetokState` map (single accessor,
/// no lock) and the egress backend. Spawned (pinned) per shard as a [`Runnable`];
/// a given rid is routed to exactly one shard.
/// no lock) and the detokenizer backend.
pub struct DetokenizerWorker {
shard: usize,
rx: flume::Receiver<DetokMsg>,
@@ -182,7 +185,7 @@ impl Runnable for DetokenizerWorker {
// Plain `recv`: exits when the `DetokMsg` channel closes (every `Senders`
// clone gone). On shutdown that happens once the API runtime drop cancels
// in-flight handlers (their `AbortGuard`s release the last clones) and
// tm-ingress/tm-egress exit — no shutdown signal needed here.
// to-scheduler/from-scheduler exit — no shutdown signal needed here.
while let Ok(msg) = self.rx.recv() {
match msg {
DetokMsg::Register {
@@ -203,7 +206,7 @@ impl Runnable for DetokenizerWorker {
},
);
}
// One decode step's chunks for this shard, batched by tm-egress.
// One decode step's chunks for this shard, batched by from-scheduler.
DetokMsg::Chunks(evs) => {
for ev in evs {
handle_chunk(&mut table, ev, &self.backend, &self.abort);
@@ -224,7 +227,7 @@ impl Runnable for DetokenizerWorker {
}
}
/// The `RequestKind::Detokenize` backend stage: tm-ingress queued this rid's
/// The `RequestKind::Detokenize` backend stage: to-scheduler queued this rid's
/// `Register` just before on this same channel, so the entry exists — deliver
/// the decoded text (or the error) through the registered sink and drop it,
/// like a one-result control request. No scheduler abort on failure: this kind
@@ -237,8 +240,8 @@ fn handle_decode(
) {
if let Some(mut st) = table.remove(rid) {
let item = match backend.decode_once(token_ids) {
Ok(text) => EgressItem::Data(text.into()),
Err(e) => EgressItem::Error(e),
Ok(text) => ResponseItem::Data(text.into()),
Err(e) => ResponseItem::Error(e),
};
let _ = st.sink.try_send(item);
st.fsm = RequestState::Completed;
@@ -249,8 +252,8 @@ fn handle_decode(
/// single `Done` frame — no detokenization, no streaming.
fn handle_result(table: &mut HashMap<Rid, DetokState>, rid: &Rid, payload: bytes::Bytes) {
if let Some(mut st) = table.remove(rid) {
let _ = st.sink.try_send(EgressItem::Control(payload));
// Egress FSM: a control request goes straight to Completed (no Streaming
let _ = st.sink.try_send(ResponseItem::Control(payload));
// Response FSM: a control request goes straight to Completed (no Streaming
// / Finalizing states — single response, never streamed).
st.fsm = RequestState::Completed;
}
@@ -274,7 +277,7 @@ fn handle_fail(
let _ = abort.send(AbortSource::Detok(rid.clone()));
let _ = st
.sink
.try_send(EgressItem::Error(Error::Internal(message)));
.try_send(ResponseItem::Error(Error::Internal(message)));
st.fsm = RequestState::Completed;
}
}
@@ -328,7 +331,7 @@ fn handle_chunk(
// — the other two terminal paths (disconnect, fail) both abort.
let _ = st.fsm.apply(Event::Error(e.clone()));
let _ = abort.send(AbortSource::Detok(rid.clone()));
let _ = st.sink.try_send(EgressItem::Error(e));
let _ = st.sink.try_send(ResponseItem::Error(e));
table.remove(&rid);
return;
}
@@ -365,7 +368,7 @@ fn handle_chunk(
if finished {
// The Done frame *is* the final frame: Finalizing → Completed.
let sent = st.sink.try_send(EgressItem::Done(ev)).is_ok();
let sent = st.sink.try_send(ResponseItem::Done(ev)).is_ok();
let _ = st.fsm.apply(if sent {
Event::FinalFrameSent
} else {
@@ -379,7 +382,7 @@ fn handle_chunk(
// silently dropping the frame would truncate the response and still look
// like success at EOS. So treat both as terminal: drop the request AND
// abort scheduler work for it.
if let Err(e) = st.sink.try_send(EgressItem::Frame(ev)) {
if let Err(e) = st.sink.try_send(ResponseItem::Frame(ev)) {
match e {
SinkError::Full => {
tracing::warn!(
@@ -441,15 +444,15 @@ mod tests {
#[test]
fn full_sink_drops_request_and_aborts_scheduler() {
// Capacity-1 sink, pre-filled so the next send hits `Full`.
let (tx, _rx) = mpsc::channel::<EgressItem>(1);
tx.try_send(EgressItem::Frame(ChunkEvent::default()))
let (tx, _rx) = mpsc::channel::<ResponseItem>(1);
tx.try_send(ResponseItem::Frame(ChunkEvent::default()))
.unwrap();
let mut table = HashMap::new();
table.insert(
Rid::from("1"),
DetokState {
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
decode_logprob_text: false,
no_stop_trim: false,
decoder: None,
@@ -510,19 +513,15 @@ mod tests {
}
/// A `Decode` job answers through the REGISTERED sink and consumes the
/// entry — the `RequestKind::Detokenize` egress contract. Uses the `Skip`
/// backend, whose decode error must arrive as an `Error` item (not vanish):
/// dropping it leaves the submitter awaiting a reply forever. (Unlike
/// `handle_fail` there is deliberately no abort lane in the signature —
/// this kind never reached the ring, so there is no scheduler work to stop.)
/// entry.
#[test]
fn decode_answers_via_registered_sink_and_consumes_the_entry() {
let (tx, mut rx) = mpsc::channel::<EgressItem>(4);
let (tx, mut rx) = mpsc::channel::<ResponseItem>(4);
let mut table = HashMap::new();
table.insert(
Rid::from("d1"),
DetokState {
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
decode_logprob_text: false,
no_stop_trim: false,
decoder: None,
@@ -537,7 +536,7 @@ mod tests {
&DetokenizerBackend::Skip,
);
let Ok(EgressItem::Error(err)) = rx.try_recv() else {
let Ok(ResponseItem::Error(err)) = rx.try_recv() else {
panic!("the decode error must reach the sink, not vanish");
};
assert!(matches!(err, Error::Validation(_)));
@@ -562,11 +561,11 @@ mod tests {
/// deterministically, without needing to find a real 64-bit collision.
#[test]
fn co_located_requests_keep_their_own_sinks() {
let (tx_a, mut rx_a) = mpsc::channel::<EgressItem>(4);
let (tx_b, mut rx_b) = mpsc::channel::<EgressItem>(4);
let (tx_a, mut rx_a) = mpsc::channel::<ResponseItem>(4);
let (tx_b, mut rx_b) = mpsc::channel::<ResponseItem>(4);
let mut table = HashMap::new();
let state = |tx| DetokState {
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
decode_logprob_text: false,
no_stop_trim: false,
decoder: None,
@@ -594,8 +593,8 @@ mod tests {
&tm_tx,
);
let ids = |rx: &mut mpsc::Receiver<EgressItem>| match rx.try_recv() {
Ok(EgressItem::Frame(ev)) => ev.token_ids,
let ids = |rx: &mut mpsc::Receiver<ResponseItem>| match rx.try_recv() {
Ok(ResponseItem::Frame(ev)) => ev.token_ids,
other => panic!("expected a frame, got {other:?}"),
};
assert_eq!(
@@ -618,12 +617,12 @@ mod tests {
finish_reason: serde_json::Value,
ids: Vec<i32>,
) -> ChunkEvent {
let (tx, mut rx) = mpsc::channel::<EgressItem>(4);
let (tx, mut rx) = mpsc::channel::<ResponseItem>(4);
let mut table = HashMap::new();
table.insert(
Rid::from("1"),
DetokState {
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
decode_logprob_text: false,
no_stop_trim,
decoder: None, // skip mode → output_ids passthrough
@@ -643,7 +642,7 @@ mod tests {
};
handle_chunk(&mut table, ev, &DetokenizerBackend::Skip, &tm_tx);
match rx.try_recv() {
Ok(EgressItem::Done(out)) => out,
Ok(ResponseItem::Done(out)) => out,
other => panic!("expected Done, got {other:?}"),
}
}
@@ -1,51 +1,44 @@
//! TokenizerManager egress thread — drains the egress ring (scheduler output
//! pushed from Python) and routes each message to the detok shard that owns its
//! `Rid::shard`. Routing is a pure function of the rid, so it matches the shard
//! the request registered with on ingress — no shared map, no lock.
//!
//! The ring carries a 1-byte frame tag: `BATCH` (a whole decode batch, fanned
//! out here into per-request chunks), `RESULT` (a single control-request JSON
//! payload, e.g. `/server_info`), or `ERROR` (a terminal per-request failure the
//! scheduler ingress couldn't decode, routed back as a 400).
//! TokenizerManager dispatcher thread — drains the from_scheduler channel and
//! routes each message to the detok shard that owns its `Rid::shard`.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes;
use crate::ids::Rid;
use crate::message::DetokMsg;
use crate::message::{
ChunkEvent, EGRESS_TAG_BATCH, EGRESS_TAG_ERROR, EGRESS_TAG_RESULT, for_each_chunk,
use crate::message::detok::DetokMsg;
use crate::message::ids::Rid;
use crate::message::response::{
ChunkEvent, DISPATCH_TAG_BATCH, DISPATCH_TAG_ERROR, DISPATCH_TAG_RESULT, for_each_chunk,
};
use crate::ring::EgressConsumer;
use crate::runtime::Runnable;
use crate::tokenizer_manager::{Senders, recv};
use crate::tokenizer_manager::channel::FromSchedulerRx;
use crate::tokenizer_manager::wiring::{Senders, recv};
/// A monotonic counter bumped once per egress-ring frame the dispatcher drains.
/// A monotonic counter bumped once per from_scheduler frame the dispatcher drains.
/// It's the rust-native equivalent of the Python `TokenizerManager`'s
/// `last_receive_tstamp`: `/health_generate` watches it advance to confirm the
/// scheduler → detok path is alive (the value itself is meaningless).
pub type ActivityCounter = Arc<AtomicU64>;
/// Egress dispatcher stage. Owns the egress-ring consumer + the detok-shard
/// Dispatcher dispatcher stage. Owns the from_scheduler consumer + the detok-shard
/// senders, so the runtime spawns it as a [`Runnable`].
pub struct Egress {
egress: EgressConsumer,
pub struct Dispatcher {
from_scheduler_rx: FromSchedulerRx,
senders: Senders,
activity: ActivityCounter,
shutdown: flume::Receiver<()>,
}
impl Egress {
impl Dispatcher {
pub fn new(
egress: EgressConsumer,
from_scheduler_rx: FromSchedulerRx,
senders: Senders,
activity: ActivityCounter,
shutdown: flume::Receiver<()>,
) -> Self {
Self {
egress,
from_scheduler_rx,
senders,
activity,
shutdown,
@@ -53,20 +46,20 @@ impl Egress {
}
}
impl Runnable for Egress {
impl Runnable for Dispatcher {
fn run(self) {
// Reused across frames (`clear` keeps capacity) — steady state allocates nothing.
let shards = self.senders.detok.len();
let shards = self.senders.detokenizer_tx.len();
let mut buckets: Vec<Vec<ChunkEvent>> = (0..shards).map(|_| Vec::new()).collect();
while let Some(bytes) = recv(self.egress.receiver(), &self.shutdown) {
while let Some(bytes) = recv(self.from_scheduler_rx.receiver(), &self.shutdown) {
let Some((&tag, body)) = bytes.split_first() else {
continue;
};
match tag {
// A whole decode batch: bucket each request by the shard owning its
// rid, then hand each shard its chunks in one send.
EGRESS_TAG_BATCH => {
DISPATCH_TAG_BATCH => {
for b in buckets.iter_mut() {
b.clear();
}
@@ -97,13 +90,13 @@ impl Runnable for Egress {
// log the same line as the recoverable one.
if decoded.rids.is_empty() {
tracing::error!(
"egress: bad batch frame named NO rids; any request in \
"from_scheduler: bad batch frame named NO rids; any request in \
it will hang (header undecodable, or empty rid column)"
);
} else {
tracing::warn!(
rids = decoded.rids.len(),
"egress: bad batch frame; failing its requests"
"from_scheduler: bad batch frame; failing its requests"
);
}
for b in buckets.iter_mut() {
@@ -113,7 +106,7 @@ impl Runnable for Egress {
// 500, not 400: the client's request was fine — the
// scheduler's own output frame was not.
let shard = rid.shard(shards);
let _ = self.senders.detok[shard].send(DetokMsg::Fail {
let _ = self.senders.detokenizer_tx[shard].send(DetokMsg::Fail {
rid,
message: "internal error: malformed scheduler output frame".into(),
});
@@ -125,36 +118,36 @@ impl Runnable for Egress {
continue;
}
let chunks = DetokMsg::Chunks(std::mem::take(b));
if self.senders.detok[i].send(chunks).is_err() {
tracing::error!("egress: detok shard closed");
if self.senders.detokenizer_tx[i].send(chunks).is_err() {
tracing::error!("from_scheduler: detok shard closed");
}
}
// Any frame off the ring = the scheduler produced output → alive.
self.activity.fetch_add(1, Ordering::Relaxed);
}
EGRESS_TAG_RESULT => {
DISPATCH_TAG_RESULT => {
if let Some((rid, msg)) = decode_result(body) {
self.route(&rid, msg);
}
}
EGRESS_TAG_ERROR => {
DISPATCH_TAG_ERROR => {
if let Some((rid, msg)) = decode_error(body) {
self.route(&rid, msg);
}
}
other => tracing::warn!(tag = other, "egress: unknown frame tag"),
other => tracing::warn!(tag = other, "from_scheduler: unknown frame tag"),
}
}
}
}
impl Egress {
impl Dispatcher {
/// Route one message to the shard owning `rid`. HOL ceiling: a slow shard stalls
/// this thread; the fix is a per-shard egress ring (see `threads::TM_CORES`).
/// this thread; the fix is a per-shard from_scheduler channel.
#[inline]
fn route(&self, rid: &Rid, msg: DetokMsg) {
if self.senders.detok_for(rid).send(msg).is_err() {
tracing::error!("egress: detok shard closed");
tracing::error!("from_scheduler: detok shard closed");
}
}
}
@@ -194,15 +187,15 @@ fn decode_error(body: &[u8]) -> Option<(Rid, DetokMsg)> {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::DetokMsg;
use crate::message::frame_egress_error;
use crate::message::detok::DetokMsg;
use crate::message::response::frame_error;
/// A framed error round-trips: `frame_egress_error` → tag stripped →
/// A framed error round-trips: `frame_error` → tag stripped →
/// `decode_error` yields the rid + a `Fail` carrying the message.
#[test]
fn error_frame_roundtrips_to_fail() {
let framed = frame_egress_error("42", "invalid request: bad field");
assert_eq!(framed[0], EGRESS_TAG_ERROR);
let framed = frame_error("42", "invalid request: bad field");
assert_eq!(framed[0], DISPATCH_TAG_ERROR);
let (rid, msg) = decode_error(&framed[1..]).expect("decodes");
let want = Rid::from("42");
assert_eq!(rid, want);
@@ -1,48 +1,33 @@
//! TokenizerManager — ingress side.
//!
//! [`Ingress`] is a single-consumer stage draining one inbox fed by both the API
//! server (fresh requests) and the Tokenizer pool (returned requests). It owns
//! the request while driving the ingress FSM and hands it off by *moving* it to
//! the next stage; nothing here is shared, so no locks.
//!
//! Edges driven here (from the design table):
//! Received → Validating
//! Validating → Normalizing (generate: sampling-param normalize/verify)
//! Validating → PreSendValidating (control: no tokenize, no sampling params)
//! Normalizing → {Encoding | Tokenizing | PreSendValidating} (by ValidationOutcome)
//! Tokenizing → PreSendValidating (on TokenizeDone, when the request returns)
//! PreSendValidating → Queued (checks needing the tokenized length)
//! Queued → ring (handed to the scheduler)
//!
//! The egress edges (Streaming/Finalizing/Completed) are driven on the egress
//! side (see `egress` + `detokenizer`).
//! TokenizerManager — to_scheduler side.
use std::collections::HashMap;
use bytes::Bytes;
use crate::error::Error;
use crate::fsm::{Event, RequestState, ValidationOutcome};
use crate::ids::Rid;
use crate::message::{
AbortReq, ControlRequest, DetokMsg, EgressItem, GenerateRequest, IngressMsg, MmRequest,
Request, RequestKind,
use crate::message::config::ServerArgs;
use crate::message::detok::DetokMsg;
use crate::message::ids::Rid;
use crate::message::io_struct::{AbortReq, ControlRequest};
use crate::message::request::{GenerateRequest, MmRequest, Request, RequestKind, SchedulerRequest};
use crate::message::response::ResponseItem;
use crate::runtime::Runnable;
use crate::tokenizer_manager::channel::ToSchedulerTx;
use crate::tokenizer_manager::wiring::{AbortSource, Senders, TmEvent};
use crate::utils::{
error::Error,
fsm::{Event, RequestState, ValidationOutcome},
};
use crate::ring::IngressProducer;
use crate::runtime::{Runnable, ServerArgs};
use crate::tokenizer_manager::{AbortSource, Senders, TmEvent};
/// Ingress FSM dispatcher stage. Owns its inbox + downstream handles, so the
/// Intake FSM dispatcher stage. Owns its inbox + downstream handles, so the
/// runtime spawns it as a [`Runnable`] rather than calling a free `run_*` fn
/// with positional arguments.
pub struct Ingress {
rx: flume::Receiver<TmEvent>,
pub struct Intake {
tok_manager_rx: flume::Receiver<TmEvent>,
/// Unbounded abort lane (see [`Senders::abort`]). Selected against `rx` so an
/// abort is handled promptly even while the bounded inbox is saturated.
abort_rx: flume::Receiver<AbortSource>,
senders: Senders,
ingress: IngressProducer,
to_scheduler_tx: ToSchedulerTx,
limits: Limits,
mm: Mm,
/// Requests parked in `Encoding` while an MM worker processes their media;
@@ -52,7 +37,7 @@ pub struct Ingress {
shutdown: flume::Receiver<()>,
}
/// The ingress side of the MM path.
/// The intake side of the MM path.
#[derive(Clone)]
pub struct Mm {
/// Whether the model is multimodal. When false, mm fields are silently
@@ -64,32 +49,22 @@ pub struct Mm {
/// Results sidecar. Purged here when a late result arrives for a request
/// that is no longer parked; otherwise it would leak, since only the
/// scheduler drain pops entries.
pub sidecar: crate::mm::Sidecar,
pub sidecar: crate::multi_modality::sidecar::Sidecar,
}
/// Longest client-supplied rid accepted. It keys the detok table and travels on
/// every chunk, so its length is a recurring cost; Python mints 32-byte uuid hex.
const MAX_RID_LEN: usize = 128;
/// What ingress admits, resolved once at boot from the scheduler's `server_args`.
/// A struct rather than more positional `new` arguments — these grew from two to
/// six, and every one of them is a `u64`/`bool` that would be trivial to swap at
/// a call site.
///
/// NOT `Default`-able on purpose. `vocab_size` and `context_len` are mandatory,
/// and their zero value is the most restrictive setting there is — a derived
/// `Default` would silently build limits that reject every request rather than
/// failing loudly. Tests construct these explicitly (see `test_limits`).
/// Resolved once at boot from the scheduler's `server_args`.
#[derive(Clone, Debug)]
pub struct Limits {
/// Token-ids-in mode: a generate request must arrive already tokenized.
pub skip_tokenizer_init: bool,
/// `model_config.vocab_size`; bounds client-supplied token ids. Mandatory —
/// [`ServerArgs::validate_mandatory`](crate::runtime::ServerArgs) rejects a
/// boot without it, so ingress can check unconditionally.
/// `model_config.vocab_size`; bounds client-supplied token ids. A required
/// field of the `ServerArgs` schema, so intake can check unconditionally.
pub vocab_size: u64,
/// `model_config.context_len`, the ceiling for input + `max_new_tokens`.
/// Mandatory, as above.
pub context_len: u64,
/// Output slots reserved on top of the input (eagle draft tokens).
pub num_reserved_tokens: u64,
@@ -99,42 +74,34 @@ pub struct Limits {
pub enable_return_hidden_states: bool,
}
impl TryFrom<&ServerArgs> for Limits {
type Error = Error;
fn try_from(sa: &ServerArgs) -> Result<Self, Self::Error> {
Ok(Self {
impl From<&ServerArgs> for Limits {
fn from(sa: &ServerArgs) -> Self {
Self {
skip_tokenizer_init: sa.skip_tokenizer_init,
vocab_size: sa
.model_config
.vocab_size
.ok_or_else(|| Error::Validation("vocab_size missing".into()))?,
context_len: sa
.model_config
.context_len
.ok_or_else(|| Error::Validation("context_len missing".into()))?,
vocab_size: sa.model_config.vocab_size,
context_len: sa.model_config.context_len,
num_reserved_tokens: sa.num_reserved_tokens,
allow_auto_truncate: sa.allow_auto_truncate,
enable_return_hidden_states: sa.enable_return_hidden_states,
})
}
}
}
impl Ingress {
impl Intake {
pub fn new(
rx: flume::Receiver<TmEvent>,
tok_manager_rx: flume::Receiver<TmEvent>,
abort_rx: flume::Receiver<AbortSource>,
senders: Senders,
ingress: IngressProducer,
to_scheduler_tx: ToSchedulerTx,
limits: Limits,
mm: Mm,
shutdown: flume::Receiver<()>,
) -> Self {
Self {
rx,
tok_manager_rx,
abort_rx,
senders,
ingress,
to_scheduler_tx,
limits,
mm,
pending_mm: HashMap::new(),
@@ -149,20 +116,20 @@ enum Lane {
Event(TmEvent),
}
impl Runnable for Ingress {
impl Runnable for Intake {
fn run(mut self) {
loop {
// Select, not a drain-then-block: an abort arriving while the inbox is
// idle must still be handled at once.
let next = flume::Selector::new()
.recv(&self.abort_rx, |r| r.ok().map(Lane::Abort))
.recv(&self.rx, |r| r.ok().map(Lane::Event))
.recv(&self.tok_manager_rx, |r| r.ok().map(Lane::Event))
.recv(&self.shutdown, |_| None)
.wait();
match next {
Some(Lane::Abort(rid)) => self.on_abort(rid),
// A fresh request and one returning from the tokenizer pool.
Some(Lane::Event(TmEvent::Ingress(req) | TmEvent::Tokenized(req))) => {
Some(Lane::Event(TmEvent::Intake(req) | TmEvent::Tokenized(req))) => {
self.drive(req)
}
Some(Lane::Event(TmEvent::MmEncoded { rid, input_ids })) => {
@@ -186,7 +153,7 @@ impl Runnable for Ingress {
}
}
impl Ingress {
impl Intake {
/// Reject a request: → `Failed`, notify the client, deregister (unconditional
/// — a no-op when nothing was registered).
/// `registered` says whether this request ever reached `register_detok`. It
@@ -198,13 +165,13 @@ impl Ingress {
fn fail(&self, req: &mut Request, err: Error, registered: bool) {
// Log only server faults (500); 4xx/499/503 are expected and would spam.
if err.http_status() == 500 {
tracing::error!(rid = %req.rid, error = %err, "ingress rejected request");
tracing::error!(rid = %req.rid, error = %err, "intake rejected request");
}
// A rejected request never reaches the scheduler drain, so purge any
// parked MM result (no-op for the common non-mm request).
self.mm.sidecar.purge(req.rid.as_str());
let _ = req.state.apply(Event::Error(err.clone()));
let _ = req.sink.try_send(EgressItem::Error(err)); // client may be gone
let _ = req.sink.try_send(ResponseItem::Error(err)); // client may be gone
if registered {
let _ = self.senders.detok_for(&req.rid).send(DetokMsg::Deregister {
rid: req.rid.clone(),
@@ -212,7 +179,7 @@ impl Ingress {
}
}
/// Drive a request through its ingress states until it terminates (failed or
/// Drive a request through its intake states until it terminates (failed or
/// pushed to the ring), is handed to the tokenizer pool (re-entering as a
/// `Tokenized` event), or is parked in `pending_mm` awaiting an MM worker
/// (re-entering via `MmEncoded` / `MmFailed`). Each arm acts and advances
@@ -315,7 +282,7 @@ impl Ingress {
work,
};
// Full = the pool can't keep up, so back-pressure like a full
// ingress ring. Disconnected = pool gone.
// to_scheduler channel. Disconnected = pool gone.
if let Err(e) = self.mm.tx.try_send(msg) {
let err = match e {
flume::TrySendError::Full(_) => Error::QueueFull,
@@ -333,7 +300,7 @@ impl Ingress {
// `Tokenized` event (PreSendValidating, or Failed on error).
// Doesn't loop.
RequestState::Tokenizing => {
if let Err(err) = self.senders.tok.send(req) {
if let Err(err) = self.senders.tokenizer_tx.send(req) {
// Pool gone (workers exited); flume hands the request back.
let mut req = err.into_inner();
// Past `Received`, so registration happened.
@@ -377,12 +344,12 @@ impl Ingress {
self.fail(&mut req, e, registered);
return;
}
// Unreachable (egress states never reach here). Reject via `fail`/
// Unreachable (request states never reach here). Reject via `fail`/
// return (not apply + continue, which would spin on a terminal state).
other => {
self.fail(
&mut req,
Error::Internal(format!("unexpected ingress state: {other:?}")),
Error::Internal(format!("unexpected state: {other:?}")),
registered,
);
return;
@@ -391,7 +358,7 @@ impl Ingress {
}
}
/// Register the egress sink with the owning detok shard (by id) so the response
/// Register the response sink with the owning detok shard (by id) so the response
/// has a home. Carries the per-request detok flags — `return_text_in_logprobs`
/// (decode logprob text on this shard) and `no_stop_trim` (keep the matched
/// stop in the output) — so the shard needs no back-reference to the request.
@@ -443,9 +410,9 @@ impl Ingress {
}
}
/// Push a bare control request (`[tag, rid, nil]`) onto the ingress ring. The
/// Push a bare control request (`[tag, rid, nil]`) onto the to_scheduler channel. The
/// scheduler dispatches it (e.g. `GetInternalStateReq`) and replies via the
/// egress ring as a single `Result`.
/// from_scheduler channel as a single `Result`.
fn push_control_to_ring(&self, mut req: Request) {
let encode = match &req.kind {
RequestKind::Control(control) => control.encode(),
@@ -461,7 +428,7 @@ impl Ingress {
}
};
// Control requests carry no tensor cell — empty `ids`.
if !self.ingress.try_push(IngressMsg {
if !self.to_scheduler_tx.try_push(SchedulerRequest {
header,
ids: Bytes::new(),
}) {
@@ -523,13 +490,13 @@ impl Ingress {
// for, so report the miss rather than assuming the scheduler was told.
match ControlRequest::AbortReq(AbortReq::new(rid.as_str().to_string(), false)).encode() {
Ok(header) => {
if !self.ingress.try_push(IngressMsg {
if !self.to_scheduler_tx.try_push(SchedulerRequest {
header,
ids: Bytes::new(),
}) {
tracing::error!(
rid = %rid,
"abort dropped: ingress ring full; the scheduler keeps generating \
"abort dropped: to_scheduler channel is full; the scheduler keeps generating \
for this request until it finishes on its own"
);
}
@@ -539,7 +506,7 @@ impl Ingress {
}
/// Serialize the tokenized request to its `TokenizedGenerateReqInput` wire and
/// push it onto the ingress ring for the scheduler. On backpressure, fail it.
/// push it onto the to_scheduler channel for the scheduler. On backpressure, fail it.
fn push_to_ring(&self, mut req: Request) {
// Only generate requests reach here (control uses `push_control_to_ring`).
// Validate + serialize while borrowing `g` immutably; the resulting `Bytes`
@@ -561,10 +528,13 @@ impl Ingress {
}
};
if !self.ingress.try_push(IngressMsg { header, ids }) {
if !self
.to_scheduler_tx
.try_push(SchedulerRequest { header, ids })
{
self.fail(&mut req, Error::QueueFull, true); // registered
}
// On success the scheduler owns the request (egress arrives by rid); we
// On success the scheduler owns the request (response arrives by rid); we
// drop our `Request` here — the detok shard holds the sink.
}
}
@@ -722,85 +692,87 @@ fn check_total_tokens(g: &mut GenerateRequest, limits: &Limits) -> Result<(), Er
#[cfg(test)]
mod tests {
use super::*;
use crate::fsm::RequestState;
use crate::message::{EgressSink, GenerateRequest, SamplingParams};
use crate::ring::{IngressConsumer, ingress_ring};
use crate::message::request::GenerateRequest;
use crate::message::response::ResponseSink;
use crate::message::sampling::SamplingParams;
use crate::tokenizer_manager::channel::{ToSchedulerRx, to_scheduler};
use crate::utils::fsm::RequestState;
use tokio::sync::mpsc;
/// An `Ingress` plus its detok-shard receiver, ring consumer (keep alive —
/// dropping it closes the ring → false QueueFull), tm inbox sender, and the
/// An `Intake` plus its detok-shard receiver, to_scheduler channel consumer (keep alive —
/// dropping it closes the channel → false QueueFull), tm inbox sender, and the
/// mm-pool receiver (keep alive — dropping it makes mm submits fail).
fn make_ingress() -> (
Ingress,
fn make_intake() -> (
Intake,
flume::Receiver<DetokMsg>,
IngressConsumer,
ToSchedulerRx,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
make_ingress_with(test_limits())
make_intake_with(test_limits())
}
fn make_ingress_with_abort(
fn make_intake_with_abort(
abort_rx: flume::Receiver<AbortSource>,
) -> (
Ingress,
Intake,
flume::Receiver<DetokMsg>,
IngressConsumer,
ToSchedulerRx,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
make_ingress_inner(test_limits(), abort_rx)
make_intake_inner(test_limits(), abort_rx)
}
fn make_ingress_with(
fn make_intake_with(
limits: Limits,
) -> (
Ingress,
Intake,
flume::Receiver<DetokMsg>,
IngressConsumer,
ToSchedulerRx,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
let (abort_tx, abort_rx) = flume::unbounded::<AbortSource>();
std::mem::forget(abort_tx); // keep the lane open; tests end by dropping tm_tx
make_ingress_inner(limits, abort_rx)
make_intake_inner(limits, abort_rx)
}
fn make_ingress_inner(
fn make_intake_inner(
limits: Limits,
abort_rx: flume::Receiver<AbortSource>,
) -> (
Ingress,
Intake,
flume::Receiver<DetokMsg>,
IngressConsumer,
ToSchedulerRx,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
let (tok_tx, _tok_rx) = flume::unbounded();
let (detok_tx, detok_rx) = flume::unbounded();
let senders = Senders {
tm: flume::unbounded().0,
abort: flume::unbounded().0,
tok: tok_tx,
detok: vec![detok_tx],
tok_manager_tx: flume::unbounded().0,
abort_tx: flume::unbounded().0,
tokenizer_tx: tok_tx,
detokenizer_tx: vec![detok_tx],
};
let (ingress_producer, consumer) = ingress_ring(16);
let (to_scheduler_tx, consumer) = to_scheduler(16);
let (tm_tx, tm_rx) = flume::unbounded();
let (mm_tx, mm_rx) = flume::unbounded();
// Keep the shutdown sender alive (leak) so its branch never fires — tests
// end `run` by dropping `tm_tx`, not by shutdown.
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let ingress = Ingress::new(
let intake = Intake::new(
tm_rx,
abort_rx,
senders,
ingress_producer,
to_scheduler_tx,
limits,
test_mm(mm_tx, true),
sd_rx,
);
(ingress, detok_rx, consumer, tm_tx, mm_rx)
(intake, detok_rx, consumer, tm_tx, mm_rx)
}
/// An [`Mm`] over `tx` with a fresh sidecar.
@@ -828,25 +800,25 @@ mod tests {
AbortSource::Detok("x".into()),
] {
let (detok_tx, detok_rx) = flume::unbounded::<DetokMsg>();
let (ingress_producer, consumer) = ingress_ring(16);
let (to_scheduler_tx, consumer) = to_scheduler(16);
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let mut ingress = Ingress::new(
let mut intake = Intake::new(
flume::unbounded().1,
flume::unbounded().1,
Senders {
tm: flume::unbounded().0,
abort: flume::unbounded().0,
tok: flume::unbounded().0,
detok: vec![detok_tx],
tok_manager_tx: flume::unbounded().0,
abort_tx: flume::unbounded().0,
tokenizer_tx: flume::unbounded().0,
detokenizer_tx: vec![detok_tx],
},
ingress_producer,
to_scheduler_tx,
test_limits(),
test_mm(flume::unbounded().0, true),
sd_rx,
);
ingress.on_abort(source.clone());
intake.on_abort(source.clone());
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Deregister { rid }) if rid.as_str() == "x"),
@@ -887,7 +859,7 @@ mod tests {
Request {
rid: id.to_string().into(),
state: RequestState::Received,
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
kind: RequestKind::Generate(Box::new(GenerateRequest {
rid: id.to_string().into(),
input_ids: Some(vec![1, 2, 3]),
@@ -985,7 +957,7 @@ mod tests {
/// `max_new_tokens: null` means "no cap", NOT "skip the checks" — the input
/// alone must still fit. Gating the whole function on `max_new_tokens` let an
/// over-long prompt through to the scheduler with no ingress error at all.
/// over-long prompt through to the scheduler with no error at all.
/// Python compares with `>=`: a prompt that exactly fills the window leaves no
/// room to generate.
#[test]
@@ -1096,11 +1068,11 @@ mod tests {
/// to the ring, after registration — so it must be deregistered, not leaked.
#[test]
fn over_context_request_deregisters_and_never_reaches_the_ring() {
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress_with(Limits {
let (mut intake, detok_rx, consumer, _tm_tx, _mm_rx) = make_intake_with(Limits {
context_len: 4,
..test_limits()
});
ingress.drive(generate_req(
intake.drive(generate_req(
33,
SamplingParams {
max_new_tokens: Some(64),
@@ -1129,12 +1101,12 @@ mod tests {
/// pins. Nothing may reach the scheduler ring.
#[test]
fn detokenize_flows_register_then_decode_and_skips_the_ring() {
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, consumer, _tm_tx, _mm_rx) = make_intake();
let (tx, mut rx) = mpsc::channel(8);
ingress.drive(Request {
intake.drive(Request {
rid: "41".into(),
state: RequestState::Received,
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
kind: RequestKind::Detokenize {
token_ids: vec![7, 8, 9],
},
@@ -1155,7 +1127,10 @@ mod tests {
consumer.drain(16).headers.is_empty(),
"must never reach the scheduler"
);
assert!(rx.try_recv().is_err(), "no egress until the shard answers");
assert!(
rx.try_recv().is_err(),
"no response until the shard answers"
);
}
/// Negative ids cannot decode (the shard's domain is `&[u32]`): rejected by
@@ -1164,17 +1139,17 @@ mod tests {
/// leak and no decode job to drop).
#[test]
fn detokenize_negative_ids_reject_before_registration() {
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, consumer, _tm_tx, _mm_rx) = make_intake();
let (tx, mut rx) = mpsc::channel(8);
ingress.drive(Request {
intake.drive(Request {
rid: "43".into(),
state: RequestState::Received,
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
kind: RequestKind::Detokenize {
token_ids: vec![1, -1],
},
});
let Ok(EgressItem::Error(err)) = rx.try_recv() else {
let Ok(ResponseItem::Error(err)) = rx.try_recv() else {
panic!("sink must receive the validation error");
};
assert_eq!(err.http_status(), 400);
@@ -1198,16 +1173,16 @@ mod tests {
let (detok_tx, detok_rx) = flume::unbounded();
let (abort_tx, abort_rx) = flume::unbounded::<AbortSource>();
let senders = Senders {
tm: flume::unbounded().0,
abort: abort_tx,
tok: tok_tx,
detok: vec![detok_tx],
tok_manager_tx: flume::unbounded().0,
abort_tx,
tokenizer_tx: tok_tx,
detokenizer_tx: vec![detok_tx],
};
let (producer, _consumer) = ingress_ring(1);
let (producer, _consumer) = to_scheduler(1);
let (_tm_tx, tm_rx) = flume::unbounded();
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let mut ingress = Ingress::new(
let mut intake = Intake::new(
tm_rx,
abort_rx,
senders,
@@ -1217,8 +1192,8 @@ mod tests {
sd_rx,
);
ingress.on_abort(AbortSource::Guard("pushed".into()));
ingress.on_abort(AbortSource::Guard("dropped".into()));
intake.on_abort(AbortSource::Guard("pushed".into()));
intake.on_abort(AbortSource::Guard("dropped".into()));
// Both deregisters land regardless of whether the ring accepted the push.
for expected in ["pushed", "dropped"] {
@@ -1252,12 +1227,12 @@ mod tests {
#[test]
fn pre_registration_failure_does_not_deregister() {
// Rejected inside `validate` (out-of-vocab id), which runs before registration.
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(41, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = Some(vec![2_000_000_000]);
}
ingress.drive(req);
intake.drive(req);
assert!(
detok_rx.try_recv().is_err(),
"a pre-registration reject must send NOTHING to the shard — a Deregister \
@@ -1265,8 +1240,8 @@ mod tests {
);
// A post-registration reject still deregisters (the leak fix stays fixed).
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
ingress.drive(generate_req(
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
intake.drive(generate_req(
42,
SamplingParams {
top_p: 2.0, // rejected by `normalize`, after registration
@@ -1284,13 +1259,13 @@ mod tests {
/// sees `Register` then `Deregister`. Regression for RSS growth on bad input.
#[test]
fn rejected_request_deregisters_from_shard() {
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
// top_p = 2.0 is outside (0, 1], so `SamplingParams::normalize` rejects it.
let bad = SamplingParams {
top_p: 2.0,
..Default::default()
};
ingress.drive(generate_req(7, bad));
intake.drive(generate_req(7, bad));
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Register { rid, .. }) if rid.as_str() == "7"),
@@ -1307,16 +1282,16 @@ mod tests {
}
/// Regression: an out-of-vocabulary client token id must be rejected at
/// ingress with a 400 — passed through, it reaches the embedding lookup
/// and kills the scheduler process (`make_ingress` bounds vocab at 1000).
/// with a 400 — passed through, it reaches the embedding lookup
/// and kills the scheduler process (`make_intake` bounds vocab at 1000).
#[test]
fn out_of_vocab_input_ids_rejected() {
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(21, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = Some(vec![1, 2_000_000_000]);
}
ingress.drive(req);
intake.drive(req);
// Rejected before registration: the only shard message is nothing at
// all, or a Deregister if registration happened first — never a push.
match detok_rx.try_recv() {
@@ -1329,23 +1304,23 @@ mod tests {
/// Same guard for negative ids and for `token_ids_logprob` entries.
#[test]
fn negative_and_logprob_token_ids_rejected() {
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(22, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = Some(vec![-1]);
}
ingress.drive(req);
intake.drive(req);
match detok_rx.try_recv() {
Err(_) | Ok(DetokMsg::Deregister { .. }) => {}
Ok(_) => panic!("negative token id must not be admitted"),
}
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(23, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.token_ids_logprob = Some(vec![999_999]);
}
ingress.drive(req);
intake.drive(req);
match detok_rx.try_recv() {
Err(_) | Ok(DetokMsg::Deregister { .. }) => {}
Ok(_) => panic!("out-of-vocab token_ids_logprob must not be admitted"),
@@ -1355,9 +1330,9 @@ mod tests {
/// A valid request is registered and handed onward — never deregistered.
#[test]
fn admitted_request_keeps_registration() {
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
// Empty map → all sampling defaults, passes normalization.
ingress.drive(generate_req(9, SamplingParams::default()));
intake.drive(generate_req(9, SamplingParams::default()));
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Register { rid, .. }) if rid.as_str() == "9"),
@@ -1372,8 +1347,8 @@ mod tests {
/// A pool return in `Failed` state (failed encode) is rejected via the same
/// path and deregistered, not leaked.
#[test]
fn tokenize_failure_deregisters_via_ingress() {
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress();
fn tokenize_failure_deregisters_via_intake() {
let (intake, detok_rx, _consumer, tm_tx, _mm_rx) = make_intake();
// The pool marks a failed encode as `Failed(err)` before returning it.
let mut req = generate_req(11, SamplingParams::default());
let _ = req
@@ -1382,7 +1357,7 @@ mod tests {
tm_tx.send(TmEvent::Tokenized(req)).unwrap();
// Close the inbox so the run loop returns after draining the one event.
drop(tm_tx);
ingress.run();
intake.run();
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Deregister { rid }) if rid.as_str() == "11"),
@@ -1397,11 +1372,11 @@ mod tests {
fn abort_deregisters_from_shard() {
// Aborts arrive on their own unbounded lane now, not the request inbox.
let (abort_tx, abort_rx) = flume::unbounded::<AbortSource>();
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress_with_abort(abort_rx);
let (intake, detok_rx, _consumer, tm_tx, _mm_rx) = make_intake_with_abort(abort_rx);
abort_tx.send(AbortSource::Guard("rid-13".into())).unwrap();
drop(abort_tx);
drop(tm_tx);
ingress.run();
intake.run();
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Deregister { rid }) if rid.as_str() == "rid-13"),
@@ -1414,7 +1389,7 @@ mod tests {
/// rejected; its registration is untouched.
#[test]
fn tokenized_return_pushes_without_deregister() {
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress();
let (intake, detok_rx, _consumer, tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(15, SamplingParams::default());
// Simulate a successful pool return: ids filled, PreSendValidating.
if let RequestKind::Generate(g) = &mut req.kind {
@@ -1423,7 +1398,7 @@ mod tests {
req.state = RequestState::PreSendValidating;
tm_tx.send(TmEvent::Tokenized(req)).unwrap();
drop(tm_tx);
ingress.run();
intake.run();
// Pushed to the ring; the shard sees nothing.
assert!(
@@ -1436,14 +1411,12 @@ mod tests {
/// deregistered, not silently dropped.
#[test]
fn tokenize_pool_gone_deregisters() {
// `make_ingress` drops the tok receiver, so `tok.send` fails.
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
// No ids → NeedsTokenize → Tokenizing branch.
let (mut intake, detok_rx, _consumer, _tm_tx, _mm_rx) = make_intake();
let mut req = generate_req(21, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = None;
}
ingress.drive(req);
intake.drive(req);
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Register { rid, .. }) if rid.as_str() == "21"),
@@ -1463,11 +1436,11 @@ mod tests {
Request {
rid: rid.to_string().into(),
state: RequestState::Received,
sink: EgressSink::Local(tx),
sink: ResponseSink::Local(tx),
kind: RequestKind::Generate(Box::new(GenerateRequest {
rid: rid.to_string().into(),
text: Some("<image> hi".into()),
mm: Some(Box::new(crate::message::MmData {
mm: Some(Box::new(crate::message::request::MmData {
image_data: Some(rmpv::Value::from("data:image/jpeg;base64,xxxx")),
..Default::default()
})),
@@ -1481,15 +1454,15 @@ mod tests {
/// sidecar entry is purged — no scheduler work runs for a dead client.
#[test]
fn abort_cancels_parked_mm_request() {
let (mut ingress, _detok_rx, consumer, _tm_tx, mm_rx) = make_ingress();
ingress.drive(mm_generate_req("mm-gone"));
let (mut intake, _detok_rx, consumer, _tm_tx, mm_rx) = make_intake();
intake.drive(mm_generate_req("mm-gone"));
mm_rx.try_recv().expect("parked to mm pool");
// The worker parks its result, as it always does before MmEncoded.
ingress.mm.sidecar.park(
intake.mm.sidecar.park(
"mm-gone".into(),
crate::mm::MmSidecarEntry {
features: crate::mm::FeatureStore::Inline(vec![]),
crate::multi_modality::sidecar::MmSidecarEntry {
features: crate::multi_modality::sidecar::FeatureStore::Inline(vec![]),
grids: vec![],
hashes: vec![],
offsets: vec![],
@@ -1497,16 +1470,16 @@ mod tests {
mrope_delta: 0,
},
);
ingress.on_abort(AbortSource::Guard("mm-gone".to_string().into()));
intake.on_abort(AbortSource::Guard("mm-gone".to_string().into()));
assert_eq!(consumer.drain(16).headers.len(), 1, "only the AbortReq");
// The late result must be dropped, not queued, and the sidecar purged.
ingress.on_mm_encoded("mm-gone".to_string().into(), vec![5, 6]);
intake.on_mm_encoded("mm-gone".to_string().into(), vec![5, 6]);
assert!(
consumer.drain(16).headers.is_empty(),
"cancelled, not queued"
);
assert!(ingress.mm.sidecar.take("mm-gone").is_none(), "entry purged");
assert!(intake.mm.sidecar.take("mm-gone").is_none(), "entry purged");
}
/// A multimodal request parks in `Encoding` (submitted to the mm worker
@@ -1514,8 +1487,8 @@ mod tests {
/// it → ring.
#[test]
fn mm_request_parks_then_mm_encoded_pushes_to_ring() {
let (mut ingress, _detok_rx, consumer, _tm_tx, mm_rx) = make_ingress();
ingress.drive(mm_generate_req("mm-1"));
let (mut intake, _detok_rx, consumer, _tm_tx, mm_rx) = make_intake();
intake.drive(mm_generate_req("mm-1"));
// Submitted to the mm pool with the typed work item; nothing on the ring yet.
let sub = mm_rx.try_recv().expect("mm pool must receive the request");
@@ -1529,7 +1502,7 @@ mod tests {
assert!(consumer.drain(16).headers.is_empty(), "parked, not queued");
// The worker returns the final expanded ids → pushed to the ring.
ingress.on_mm_encoded("mm-1".to_string().into(), vec![5, 6, 7, 8]);
intake.on_mm_encoded("mm-1".to_string().into(), vec![5, 6, 7, 8]);
let batch = consumer.drain(16);
assert_eq!(batch.headers.len(), 1);
assert_eq!(
@@ -1542,14 +1515,14 @@ mod tests {
/// A worker failure rejects the parked request (deregister, no ring push).
#[test]
fn mm_failure_rejects_parked_request() {
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
ingress.drive(mm_generate_req("mm-2"));
let (mut intake, detok_rx, consumer, _tm_tx, _mm_rx) = make_intake();
intake.drive(mm_generate_req("mm-2"));
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Register { .. })),
"registered before parking",
);
ingress.on_mm_failed("mm-2".to_string().into(), "bad image".into());
intake.on_mm_failed("mm-2".to_string().into(), "bad image".into());
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Deregister { rid })
if rid.as_str() == "mm-2"),
@@ -1566,29 +1539,29 @@ mod tests {
let (tok_tx, tok_rx) = flume::unbounded();
let (detok_tx, _detok_rx) = flume::unbounded();
let senders = Senders {
tm: flume::unbounded().0,
abort: flume::unbounded().0,
tok: tok_tx,
detok: vec![detok_tx],
tok_manager_tx: flume::unbounded().0,
abort_tx: flume::unbounded().0,
tokenizer_tx: tok_tx,
detokenizer_tx: vec![detok_tx],
};
let (ingress_producer, _consumer) = ingress_ring(16);
let (to_scheduler_tx, _consumer) = to_scheduler(16);
let (_tm_tx, tm_rx) = flume::unbounded();
let (mm_tx, mm_rx) = flume::unbounded();
let (abort_tx, abort_rx) = flume::unbounded::<AbortSource>();
std::mem::forget(abort_tx);
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let mut ingress = Ingress::new(
let mut intake = Intake::new(
tm_rx,
abort_rx,
senders,
ingress_producer,
to_scheduler_tx,
test_limits(),
test_mm(mm_tx, false),
sd_rx,
);
ingress.drive(mm_generate_req("mm-3"));
intake.drive(mm_generate_req("mm-3"));
assert!(
mm_rx.try_recv().is_err(),
"mm disabled: nothing submitted to the mm channel",
@@ -1603,9 +1576,9 @@ mod tests {
/// panicking (e.g. hash-collision overwrite) — regression guard.
#[test]
fn late_mm_result_is_dropped() {
let (mut ingress, _detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
ingress.on_mm_encoded("ghost".to_string().into(), vec![1]);
ingress.on_mm_failed("ghost".to_string().into(), "boom".into());
let (mut intake, _detok_rx, consumer, _tm_tx, _mm_rx) = make_intake();
intake.on_mm_encoded("ghost".to_string().into(), vec![1]);
intake.on_mm_failed("ghost".to_string().into(), "boom".into());
assert!(consumer.drain(16).headers.is_empty());
}
}
@@ -14,11 +14,11 @@
use std::path::Path;
use std::sync::Arc;
use crate::error::Error;
use crate::fsm::Event;
use crate::message::{Request, RequestKind, TokenIds};
use crate::message::request::{Request, RequestKind};
use crate::message::types::TokenIds;
use crate::runtime::Runnable;
use crate::tokenizer_manager::TmEvent;
use crate::tokenizer_manager::wiring::TmEvent;
use crate::utils::{error::Error, fsm::Event};
/// Pluggable text→token-ids backend. `Send + Sync` so one instance is shared
/// (read-only) across all pinned workers.
@@ -230,8 +230,10 @@ impl Runnable for TokenizerWorker {
#[cfg(test)]
mod tests {
use super::*;
use crate::fsm::RequestState;
use crate::message::{EgressSink, GenerateRequest, RequestKind, SamplingParams};
use crate::message::request::{GenerateRequest, RequestKind};
use crate::message::response::ResponseSink;
use crate::message::sampling::SamplingParams;
use crate::utils::fsm::RequestState;
use tokio::sync::mpsc;
/// One token per whitespace-separated word, so a stop's token count differs
@@ -266,7 +268,7 @@ mod tests {
.send(Request {
rid: "1".into(),
state: RequestState::Tokenizing,
sink: EgressSink::Local(sink_tx),
sink: ResponseSink::Local(sink_tx),
kind: RequestKind::Generate(Box::new(GenerateRequest {
rid: "1".into(),
text: Some("hello world".into()),
@@ -326,7 +328,7 @@ mod tests {
.send(Request {
rid: "1".into(),
state: RequestState::Tokenizing,
sink: EgressSink::Local(tokio::sync::mpsc::channel(4).0),
sink: ResponseSink::Local(tokio::sync::mpsc::channel(4).0),
kind: RequestKind::Generate(Box::new(GenerateRequest {
rid: "1".into(),
text: Some("hi".into()),
@@ -0,0 +1,77 @@
//! The flume fabric between stages: the request-loop inbox ([`TmEvent`]), the
//! abort lane ([`AbortSource`]), the producer-side handles ([`Senders`]), and
//! the shutdown-aware [`recv`].
use crate::message::detok::DetokMsg;
use crate::message::ids::Rid;
use crate::message::request::Request;
/// Blocking receive that also wakes on shutdown: returns `None` when `rx` closes
/// *or* the `shutdown` sender is dropped.
pub fn recv<T>(rx: &flume::Receiver<T>, shutdown: &flume::Receiver<()>) -> Option<T> {
flume::Selector::new()
.recv(rx, |r| r.ok())
.recv(shutdown, |_| None)
.wait()
}
/// Events into the TokenizerManager request loop. API server + tokenizer pool
/// share this one inbox, keeping the loop a single consumer (no `select`).
pub enum TmEvent {
/// A freshly received request from the API server.
Intake(Request),
/// A request back from the tokenizer pool: `PreSendValidating` (ids filled)
/// on success, or `Failed` on a tokenize error. `drive` handles both.
Tokenized(Request),
/// An MM worker finished a request parked in `Encoding`: `input_ids` are the
/// final placeholder-expanded prompt ids. The buffers ride the rid-keyed
/// sidecar (`Server.take_mm`), not this event.
MmEncoded { rid: Rid, input_ids: Vec<i32> },
/// An MM worker rejected a request parked in `Encoding` (bad media URL,
/// unsupported modality, preprocess error, …).
MmFailed { rid: Rid, message: String },
}
/// The source of the abort request. Both variants do the same work in
/// [`Intake::on_abort`] — deregister the detok entry, tell the scheduler to
/// stop — and the source is kept for diagnostics.
///
/// There is no in-flight rid registry to keep consistent, and so no release
/// ordering to get wrong: [`Rid::from_client`] makes every client-supplied rid
/// internally unique, so a resubmit of the "same" rid is a different `Rid` and
/// cannot be tangled up with an abort still in flight for the original.
#[derive(Clone, Debug)]
pub enum AbortSource {
/// From an `AbortGuard` drop. Owns the release.
Guard(Rid),
/// From a detokenizer terminal path. Aborts the scheduler work.
Detok(Rid),
}
impl AbortSource {
pub fn rid(&self) -> &Rid {
match self {
Self::Guard(rid) | Self::Detok(rid) => rid,
}
}
}
/// Producer-side handles, cloned into every stage that needs to emit.
#[derive(Clone)]
pub struct Senders {
/// → TokenizerManager loop.
pub tok_manager_tx: flume::Sender<TmEvent>,
/// → the same loop, but UNBOUNDED and abort-only.
pub abort_tx: flume::Sender<AbortSource>,
/// → Tokenizer pool (CPU-bound, pinned threads).
pub tokenizer_tx: flume::Sender<Request>,
/// → Detokenizer shards, indexed by `Rid::shard(detok.len())`.
pub detokenizer_tx: Vec<flume::Sender<DetokMsg>>,
}
impl Senders {
#[inline]
pub fn detok_for(&self, rid: &Rid) -> &flume::Sender<DetokMsg> {
&self.detokenizer_tx[rid.shard(self.detokenizer_tx.len())]
}
}
+6
View File
@@ -1,6 +1,12 @@
//! Shared helpers with no home in a pipeline stage.
pub mod environ;
pub mod error;
pub mod fsm;
pub mod logging;
pub mod regex;
pub mod response;
pub mod runtime;
pub mod serialize;
pub mod sock;
pub mod threads;
@@ -20,8 +20,8 @@ pub enum Error {
#[error("detokenize failed: {0}")]
Detokenize(String),
/// Ingress ring full / scheduler not draining. Surfaced as backpressure.
#[error("ingress queue full")]
/// To-scheduler channel full. Surfaced as backpressure.
#[error("to_scheduler channel full")]
QueueFull,
/// Client went away mid-stream. Drives `Aborted`, not `Failed`.
@@ -12,7 +12,7 @@
//! Aborted
//! ```
use crate::error::Error;
use super::error::Error;
#[derive(Debug, Clone)]
pub enum RequestState {
@@ -36,7 +36,7 @@ pub enum RequestState {
Aborted,
}
/// Outcome of validation, selecting the ingress branch.
/// Outcome of validation.
#[derive(Debug, Clone, Copy)]
pub enum ValidationOutcome {
/// Has multimodal inputs → Encoding, where an MM worker runs the native
@@ -52,7 +52,7 @@ pub enum ValidationOutcome {
/// design's transition table.
#[derive(Debug)]
pub enum Event {
// --- ingress ---
// --- request ---
Validated(ValidationOutcome),
NeedsNormalize,
EncodeDone,
@@ -60,7 +60,7 @@ pub enum Event {
/// The pre-send checks passed; the request may be pushed to the ring.
PreSendValidated,
SchedulerPicked,
// --- egress ---
// --- response ---
Chunk {
finish: bool,
},
@@ -113,7 +113,7 @@ impl RequestState {
}
let next = match (&*self, &event) {
// ingress
// request
(Received, Validated(_)) => Validating,
// Generate requests pass through Normalizing (sampling-param
// normalize/verify); control requests skip it, having none.
@@ -127,12 +127,12 @@ impl RequestState {
// pre-send checks: expanded image tokens count against the same
// input + max_new_tokens ceiling as tokenized text.
(Encoding, EncodeDone) => PreSendValidating,
// Every ingress branch funnels through the pre-send checks, so they
// Every to-scheduler branch funnels through the pre-send checks, so they
// run exactly once per request no matter how it got its ids.
(Tokenizing, TokenizeDone) => PreSendValidating,
(PreSendValidating, PreSendValidated) => Queued,
(Queued, SchedulerPicked) => Streaming { chunks_sent: 0 },
// egress
// response
(Streaming { chunks_sent }, Chunk { finish: false }) => Streaming {
chunks_sent: chunks_sent + 1,
},
@@ -154,7 +154,7 @@ mod tests {
state
}
/// Every ingress branch — control, client-supplied ids, and text through the
/// Every to-scheduler branch — control, client-supplied ids, and text through the
/// tokenizer pool — must land in `PreSendValidating`, because that is where
/// the checks needing the final `input_ids` run. A branch that reached
/// `Queued` directly would skip them silently.
+23
View File
@@ -0,0 +1,23 @@
//! Process-wide `tracing` setup for the embedded server.
use std::sync::OnceLock;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter;
/// Keeps the non-blocking log writer's background thread alive for the process
/// lifetime (dropping the guard would stop log delivery).
static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
/// Install the global `tracing` subscriber once; a no-op if the host process
/// (or an earlier call) already set one.
pub fn init_tracing() {
let (writer, guard) = tracing_appender::non_blocking(std::io::stdout());
let _ = LOG_GUARD.set(guard);
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(writer)
.try_init();
}
+4 -29
View File
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use crate::error::Error;
use super::error::Error;
/// `MAX_LEN` from Python's `get_max_seq_length`: the bound for an *unbounded* stop
/// regex (`\d+`, `.*`, …) or one we can't statically size — the scheduler then
@@ -221,7 +221,7 @@ const ADMISSION_CACHE_CAP: usize = 512;
/// it is HIR translation, which expands `\w`/`\W` into large Unicode class unions.
/// A 256-byte `\W`-heavy pattern (exactly [`MAX_STOP_REGEX_LEN`]) measures 574 µs,
/// and a request may carry [`MAX_STOP_REGEX_COUNT`] of them — 18 ms of admission on
/// the single ingress thread, re-derived from scratch on every request. It
/// the single to-scheduler thread, re-derived from scratch on every request. It
/// multiplies through a batch, because one `sampling_params` object broadcasts to
/// every item: a 13.6 KB body measured **1.01 s**, during which that thread serves
/// no other request, no abort and no health probe.
@@ -234,7 +234,7 @@ const ADMISSION_CACHE_CAP: usize = 512;
/// Cleared wholesale when full rather than evicted one at a time: that is what
/// CPython's `re` does, and it keeps the hot path one lookup with no LRU
/// bookkeeping. The lock is held across a hash lookup and nothing else, and is
/// taken almost exclusively by the one ingress thread.
/// taken almost exclusively by the one to-scheduler thread.
static ADMISSION_CACHE: LazyLock<Mutex<HashMap<Box<str>, usize>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -322,24 +322,6 @@ impl<'a> RegexPattern<'a> {
/// Validate a `stop_regex` before it can reach the scheduler, returning the parsed
/// AST so the caller can derive its bound without parsing again.
///
/// Two independent classes of rejection, for two different reasons:
///
/// * **Dialect.** CPython's `re` is the engine that actually runs this pattern, and
/// `regex-syntax` is neither a superset nor a subset of it. The rows where
/// `regex-syntax` is *wider* are the dangerous ones — a pattern admitted here but
/// uncompilable there reaches `re.search` on the decode hot path, where the raised
/// error is uncaught and takes the scheduler down. Hence the invariant is
/// one-directional: **anything admitted here must compile in Python**, while
/// rejecting a pattern Python would have accepted costs one client a 400. The
/// asymmetry is deliberate, and it is why the checks below only ever add
/// rejections.
///
/// * **Cost.** The scheduler re-matches this against the output tail on *every*
/// decode step, inside `re`'s C loop with the GIL held, where no timeout or signal
/// can interrupt it. Repetition counts, nested unbounded repetitions, quantified
/// assertions and ambiguous alternations are refused on those grounds alone —
/// they are all valid Python.
fn validate(pattern: &str) -> Result<regex_syntax::ast::Ast, Error> {
reject_python_incompatible(pattern)?;
let ast = regex_syntax::ast::parse::ParserBuilder::new()
@@ -815,14 +797,7 @@ mod tests {
// ---- AMBIGUITY (rounds 6-8). Every one compiles cleanly on both sides
// and raises nothing, so the `except (re.error, RecursionError)` seatbelt
// in `_check_str_based_finish` is irrelevant: the match simply never
// returns, inside GIL-holding CPython C that no watchdog can preempt.
//
// Two distinct kill modes, both represented:
// * unbounded bound -> `_stop_match_tail_len` hands `re.search` the
// WHOLE accumulated output, so cost grows every decode step;
// * finite bound -> a fixed but ruinous cost paid EVERY step forever,
// and `MAX_STOP_REGEX_COUNT` allows 64 patterns per request.
// Timings on a matching subject; see the module docs for the method.
// returns.
case(
"(?:.|.)*Z",
Policy::MustReject,
@@ -1,14 +1,14 @@
//! Runtime bootstrap: wires channels, pins CPU-bound pools, starts the tokio
//! API server, and returns a handle the Python boundary uses for
//! `recv_requests` (ingress drain) and `push_batch` (egress push).
//! `recv_requests` and `push_decode_result_batch`.
//!
//! Thread layout:
//! * API server — tokio multi-thread runtime (I/O bound), pinned core set A
//! * Tokenizer — N pinned OS threads (CPU bound), core set B
//! * Detokenizer — M pinned OS threads / shards (CPU bound), core set C
//! * TM ingress — 1 thread driving the ingress FSM
//! * TM egress — 1 thread draining the egress ring → detok shards
//! * MM workers — K unpinned OS threads, spawned late via
//! * API server — tokio multi-thread runtime (I/O bound), pinned core set A
//! * Tokenizer — N pinned OS threads (CPU bound), core set B
//! * Detokenizer — M pinned OS threads (CPU bound), core set C
//! * To_scheduler — 1 thread driving the FSM
//! * From_scheduler — 1 thread draining the scheduler → detok shards
//! * MM workers — K unpinned OS threads, spawned late via
//! [`Runtime::spawn_mm_pool`] (multimodal models only)
//!
//! Keeping CPU-bound tokenize/detokenize off the async executor avoids stalling
@@ -17,41 +17,42 @@
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
mod config;
mod runnable;
mod threads;
use crate::message::config::RuntimeConfig;
use crate::message::detok::DetokMsg;
pub use config::{DefaultSamplingParams, RuntimeConfig, RustServerServerArgs, ServerArgs};
use crate::message::DetokMsg;
use crate::ring::{
EgressConsumer, EgressProducer, IngressConsumer, IngressProducer, egress_ring, ingress_ring,
use super::threads::{join_all_with_timeout, plan_cores, spawn_pool};
use crate::tokenizer_manager::channel::{
FromSchedulerRx, FromSchedulerTx, ToSchedulerRx, ToSchedulerTx, from_scheduler, to_scheduler,
};
use crate::runtime::threads::{plan_cores, spawn_pool};
use crate::tokenizer_manager::{Senders, TmEvent};
use crate::tokenizer_manager::wiring::{Senders, TmEvent};
use crate::utils::sock::bind_tcp_listener;
use crate::{api_server, detokenizer, tokenizer, tokenizer_manager};
use crate::{
api_server, tokenizer_manager, tokenizer_manager::detokenizer, tokenizer_manager::tokenizer,
};
// Re-export so stages keep importing `crate::runtime::Runnable`.
pub use runnable::Runnable;
/// A pipeline stage that owns its channel handles + config and runs a blocking
/// loop until its inbox closes.
pub trait Runnable: Send + 'static {
fn run(self);
}
/// Live runtime. Held by the pyo3 bridge; the Python boundary reads `ingress`
/// and `egress`. `request_shutdown` (also run on `Drop`) stops every stage.
/// Live runtime. Held by the pyo3 bridge; the Python boundary reads the `to_scheduler_rx` channel,
/// and write to `from_scheduler_tx` channel. `request_shutdown` (also run on `Drop`) stops every stage.
pub struct Runtime {
pub ingress: IngressConsumer,
pub egress: EgressProducer,
pub to_scheduler_rx: ToSchedulerRx,
pub from_scheduler_tx: FromSchedulerTx,
/// Requests parked in `Encoding`, drained by the MM worker pool
/// (`Server.start_mm_workers`). Stays empty for non-multimodal models —
/// ingress never routes to it.
pub mm: flume::Receiver<crate::message::MmRequest>,
/// Back-channel for the MM workers' `MmEncoded` / `MmFailed` into tm-ingress.
pub tm: flume::Sender<TmEvent>,
/// request never routes to it.
pub to_mm_worker_rx: flume::Receiver<crate::message::request::MmRequest>,
/// Back-channel for the MM workers' `MmEncoded` / `MmFailed` into to_scheduler.
pub from_mm_worker_tx: flume::Sender<TmEvent>,
/// The loaded tokenizer, shared with the MM worker path (`None` under
/// `skip_tokenizer_init`).
pub tokenizer: Option<Arc<dyn tokenizer::TextTokenizer>>,
/// MM results parked between a worker's `MmEncoded` and the scheduler drain
/// (`Server.take_mm`).
pub mm_sidecar: crate::mm::Sidecar,
pub mm_sidecar: crate::multi_modality::sidecar::Sidecar,
/// Worker join handles, joined by `request_shutdown` / `Drop`.
threads: Mutex<Vec<JoinHandle<()>>>,
/// The single shutdown sender.
@@ -71,40 +72,23 @@ impl Runtime {
/// MM preprocessing floats over that whole set (rather than owning cores
/// that idle between bursts) and never preempts the scheduler's reserved
/// cores.
pub fn spawn_mm_pool(&self, workers: usize, ctx: Arc<crate::mm::Context>) {
pub fn spawn_mm_pool(&self, workers: usize, ctx: Arc<crate::multi_modality::worker::Context>) {
let mut threads = self.threads.lock().unwrap();
spawn_pool("mm-worker", None, workers.max(1), &mut threads, |_| {
crate::mm::MmWorker::new(self.mm.clone(), self.tm.clone(), ctx.clone())
crate::multi_modality::worker::MmWorker::new(
self.to_mm_worker_rx.clone(),
self.from_mm_worker_tx.clone(),
ctx.clone(),
)
});
}
/// Stop the runtime and join every worker thread (with a bounded wait).
///
/// Dropping `shutdown_tx` wakes the tm-ingress/tm-egress selectors (which
/// otherwise never see their inbox close — one self-holds a `tm` sender, the
/// other's inbox is the Python-fed egress ring). Those exit and drop their
/// `Senders` clones; the api thread's `serve` returns non-gracefully, so its
/// `block_on` unwinds and the api tokio runtime is dropped — cancelling
/// in-flight handlers, whose `AbortGuard`s release the remaining clones. With
/// every clone gone the tok/detok channels close and those workers exit.
///
/// In-flight requests are **aborted**, not drained — this is the hard-stop
/// path (also run on `Drop`). Clients of aborted requests retry.
pub fn request_shutdown(&self) {
drop(self.shutdown_tx.lock().unwrap().take());
let handles: Vec<JoinHandle<()>> = self.threads.lock().unwrap().drain(..).collect();
if handles.is_empty() {
return; // Idempotent: a `Drop` after an explicit shutdown has nothing to join.
}
// Join off-thread and wait with a deadline: a stuck worker can't wedge exit.
let (done_tx, done_rx) = flume::bounded::<()>(1);
std::thread::spawn(move || {
for h in handles {
let _ = h.join();
}
let _ = done_tx.send(());
});
if done_rx.recv_timeout(SHUTDOWN_JOIN_TIMEOUT).is_err() {
// Idempotent: a `Drop` after an explicit shutdown finds nothing to join.
let handles = std::mem::take(&mut *self.threads.lock().unwrap());
if !join_all_with_timeout(handles, SHUTDOWN_JOIN_TIMEOUT) {
tracing::warn!(
"shutdown: workers did not exit within {SHUTDOWN_JOIN_TIMEOUT:?}; abandoning join"
);
@@ -118,55 +102,55 @@ impl Drop for Runtime {
}
}
/// Boot the whole frontend. Returns once threads are spawned (non-blocking),
/// so the Python caller regains control of the GIL immediately. `Err` on a
/// startup misconfiguration (e.g. no tokenizer for a non-skip server).
/// Boot the whole frontend. Returns once threads are spawned (non-blocking).
/// `Err` on a startup misconfiguration (e.g. no tokenizer for a non-skip server).
pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
let (shutdown_tx, shutdown_rx) = flume::unbounded::<()>();
let mut threads = Vec::new();
let plan = plan_cores(&cfg);
// --- rings (Rust ↔ Python) ---
let (ingress_tx, ingress_rx): (IngressProducer, IngressConsumer) =
ingress_ring(cfg.rust_server_args.ingress_ring_cap);
let (egress_tx, egress_rx): (EgressProducer, EgressConsumer) =
egress_ring(cfg.rust_server_args.egress_ring_cap);
let (to_scheduler_tx, to_scheduler_rx): (ToSchedulerTx, ToSchedulerRx) =
to_scheduler(cfg.rust_server_args.to_scheduler_cap);
let (from_scheduler_tx, from_scheduler_rx): (FromSchedulerTx, FromSchedulerRx) =
from_scheduler(cfg.rust_server_args.from_scheduler_cap);
// --- inter-stage channels ---
let (tm_tx, tm_rx) = flume::bounded::<TmEvent>(cfg.rust_server_args.channel_cap);
let (tok_tx, tok_rx) =
flume::bounded::<crate::message::Request>(cfg.rust_server_args.channel_cap);
let (tok_manager_tx, tok_manager_rx) =
flume::bounded::<TmEvent>(cfg.rust_server_args.channel_cap);
let (tokenizer_tx, tokenizer_rx) =
flume::bounded::<crate::message::request::Request>(cfg.rust_server_args.channel_cap);
// Encoding → MM worker pool. Bounded like the other stage edges so a slow
// pool back-pressures instead of buffering unboundedly.
let (mm_tx, mm_rx) =
flume::bounded::<crate::message::MmRequest>(cfg.rust_server_args.channel_cap);
let (mm_worker_tx, mm_worker_rx) =
flume::bounded::<crate::message::request::MmRequest>(cfg.rust_server_args.channel_cap);
let detokenizer_worker_num = cfg.server_args.detokenizer_worker_num;
let mut detok_tx = Vec::with_capacity(detokenizer_worker_num);
let mut detok_rx = Vec::with_capacity(detokenizer_worker_num);
let mut detokenizer_tx = Vec::with_capacity(detokenizer_worker_num);
let mut detokenizer_rx = Vec::with_capacity(detokenizer_worker_num);
for _ in 0..detokenizer_worker_num {
let (tx, rx) = flume::bounded::<DetokMsg>(cfg.rust_server_args.channel_cap);
detok_tx.push(tx);
detok_rx.push(rx);
detokenizer_tx.push(tx);
detokenizer_rx.push(rx);
}
// Aborts get their own UNBOUNDED lane: on the bounded inbox they are dropped
// exactly under the overload that makes them necessary (see `Senders::abort`).
let (abort_tx, abort_rx) = flume::unbounded::<crate::tokenizer_manager::AbortSource>();
let (abort_tx, abort_rx) = flume::unbounded::<crate::tokenizer_manager::wiring::AbortSource>();
let senders = Senders {
tm: tm_tx.clone(),
abort: abort_tx.clone(),
tok: tok_tx,
detok: detok_tx,
tok_manager_tx: tok_manager_tx.clone(),
abort_tx: abort_tx.clone(),
tokenizer_tx,
detokenizer_tx,
};
// `skip_tokenizer_init`: clients send token ids and receive token ids — no
// tokenizer is loaded, and the egress emits raw `output_ids` (no decode).
// tokenizer is loaded, and the server emits raw `output_ids` (no decode).
let skip_tokenizer_init = cfg.server_args.skip_tokenizer_init;
// The same instance is shared by the tokenizer pool (encode) and the detok
// shards (decode); `None` only under `skip_tokenizer_init`.
let dyn_tokenizer = tokenizer::load_tokenizer(
// Empty only in minimal standalone blobs (the Python dump always
// Empty only in standalone (test) configs (the Python handoff always
// resolves it); empty → no tokenizer, allowed only under
// `skip_tokenizer_init`.
(!cfg.server_args.tokenizer_path.is_empty()).then_some(&*cfg.server_args.tokenizer_path),
@@ -179,8 +163,8 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
.as_ref()
.map(|t| Arc::new(tokenizer::DynamoTokenizer::new(t.clone())) as _);
// Shared: MM workers park, the Python drain pops, tm-ingress purges.
let mm_sidecar: crate::mm::Sidecar = Default::default();
// Shared: MM workers park, the Python drain pops.
let mm_sidecar: crate::multi_modality::sidecar::Sidecar = Default::default();
// --- Detokenizer shards (pinned, CPU bound) ---
{
@@ -194,12 +178,12 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
let detok_cores = plan.as_ref().map(|p| p.detok.clone());
// Each shard owns its receiver outright (one consumer per shard), so the
// owned `detok_rx` Vec is moved out element-by-element via the iterator.
let count = detok_rx.len();
let mut rxs = detok_rx.into_iter();
let count = detokenizer_rx.len();
let mut detokenizer_rxs = detokenizer_rx.into_iter();
spawn_pool("detokenizer", detok_cores, count, &mut threads, |i| {
detokenizer::DetokenizerWorker::new(
i,
rxs.next().unwrap(),
detokenizer_rxs.next().unwrap(),
backend.clone(),
abort_tx.clone(),
)
@@ -208,7 +192,7 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
// --- Tokenizer pool (pinned, CPU bound) ---
// Only spawned when a real tokenizer is loaded; under `skip_tokenizer_init`
// there is none and ingress never routes to the pool, so we skip it.
// there is none and request never routes to the pool, so we skip it.
if let Some(tokenizer) = &text_tokenizer {
// Reuse the single loaded tokenizer (shared with the detok shards).
let tokenizer = tokenizer.clone();
@@ -220,29 +204,35 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
tok_cores,
cfg.server_args.tokenizer_worker_num,
&mut threads,
|_i| tokenizer::TokenizerWorker::new(tok_rx.clone(), tm_tx.clone(), tokenizer.clone()),
|_i| {
tokenizer::TokenizerWorker::new(
tokenizer_rx.clone(),
tok_manager_tx.clone(),
tokenizer.clone(),
)
},
);
}
// Egress heartbeat: bumped per drained frame, watched by `/health_generate`.
let egress_activity: tokenizer_manager::ActivityCounter =
// Response heartbeat: bumped per drained frame, watched by `/health_generate`.
let response_activity: tokenizer_manager::from_scheduler::ActivityCounter =
Arc::new(std::sync::atomic::AtomicU64::new(0));
// --- Egress dispatcher: drains egress ring → routes chunks to shards ---
// --- Response dispatcher: drains from_scheduler channel → routes chunks to shards ---
{
// First TM core; egress is the hotter router (every output token). One
// First TM core; from_scheduler is the hotter router (every output token). One
// worker today via `spawn_pool`, so sharding by `Rid::shard` later (see
// `TM_CORES`) is just a larger count + per-shard receivers.
let cores = plan
.as_ref()
.and_then(|p| p.tm.first().copied())
.map(|c| vec![c]);
let mut egress_rx = Some(egress_rx); // moved into the single worker
let activity = egress_activity.clone();
let mut from_scheduler_rx = Some(from_scheduler_rx); // moved into the single worker
let activity = response_activity.clone();
let shutdown_rx = shutdown_rx.clone();
spawn_pool("tm-egress", cores, 1, &mut threads, |_| {
tokenizer_manager::Egress::new(
egress_rx.take().unwrap(),
spawn_pool("from-scheduler", cores, 1, &mut threads, |_| {
tokenizer_manager::from_scheduler::Dispatcher::new(
from_scheduler_rx.take().unwrap(),
senders.clone(),
activity.clone(),
shutdown_rx.clone(),
@@ -250,7 +240,7 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
});
}
// --- TokenizerManager ingress loop ---
// --- TokenizerManager to_scheduler loop ---
{
// Second TM core when present, else share the first (1-core / API-set
// fallback) — still off the CPU-bound pool cores either way.
@@ -258,22 +248,21 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
.as_ref()
.and_then(|p| p.tm.get(1).or_else(|| p.tm.first()).copied())
.map(|c| vec![c]);
let limits = tokenizer_manager::Limits::try_from(&*cfg.server_args)
.map_err(|e| format!("ingress limits: {e}"))?;
let mm = tokenizer_manager::Mm {
let limits = tokenizer_manager::to_scheduler::Limits::from(&*cfg.server_args);
let mm = tokenizer_manager::to_scheduler::Mm {
enabled: cfg.server_args.model_is_multimodal(),
tx: mm_tx,
tx: mm_worker_tx,
sidecar: mm_sidecar.clone(),
};
let mut parts = Some((tm_rx, ingress_tx)); // moved into the single worker
let mut parts = Some((tok_manager_rx, to_scheduler_tx)); // moved into the single worker
let shutdown_rx = shutdown_rx.clone();
spawn_pool("tm-ingress", cores, 1, &mut threads, |_| {
let (tm_rx, ingress_tx) = parts.take().unwrap();
tokenizer_manager::Ingress::new(
tm_rx,
spawn_pool("to-scheduler", cores, 1, &mut threads, |_| {
let (tok_manager_rx, to_scheduler_tx) = parts.take().unwrap();
tokenizer_manager::to_scheduler::Intake::new(
tok_manager_rx,
abort_rx.clone(),
senders.clone(),
ingress_tx,
to_scheduler_tx,
limits.clone(),
mm.clone(),
shutdown_rx.clone(),
@@ -286,7 +275,7 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
let cfg = cfg.clone();
let api_cores = plan.as_ref().map(|p| p.api.clone());
let senders = senders.clone();
let api_activity = egress_activity.clone();
let response_activity = response_activity.clone();
let shutdown_rx = shutdown_rx.clone();
// Bind synchronously so an unavailable port (EADDRINUSE) is a hard
// startup error. The `?` drops `shutdown_tx`/`senders`, which stops the
@@ -299,7 +288,7 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
.spawn(move || {
let mut builder = tokio::runtime::Builder::new_multi_thread();
builder
.worker_threads(cfg.rust_server_args.api_worker_num)
.worker_threads(cfg.rust_server_args.http_api_worker_num)
.enable_all();
if let Some(cores) = api_cores {
let next = std::sync::atomic::AtomicUsize::new(0);
@@ -311,13 +300,13 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
});
}
let rt = builder.build().expect("build api runtime");
rt.block_on(api_server::serve(
rt.block_on(api_server::app::serve(
listener,
senders,
cfg.rust_server_args.channel_cap,
cfg.server_args.clone(),
// Egress heartbeat watched by `/health_generate`.
api_activity,
// Response heartbeat watched by `/health_generate`.
response_activity,
shutdown_rx,
))
})
@@ -326,10 +315,10 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
}
Ok(Runtime {
ingress: ingress_rx,
egress: egress_tx,
mm: mm_rx,
tm: tm_tx,
to_scheduler_rx,
from_scheduler_tx,
to_mm_worker_rx: mm_worker_rx,
from_mm_worker_tx: tok_manager_tx,
tokenizer: text_tokenizer,
mm_sidecar,
threads: Mutex::new(threads),
@@ -340,16 +329,16 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::config::{RuntimeConfig, RustServerServerArgs, ServerArgs};
/// Minimal boot args. `skip_tokenizer_init` avoids loading a tokenizer/detok
/// model; `model_config` carries the two fields `Limits::from_server_args`
/// requires. They are mandatory at boot, so a fixture without them panics the
/// runtime instead of exercising what these tests are about — `start` does not
/// run `ServerArgs::validate_mandatory` itself, `Server::start` does.
const TEST_SERVER_ARGS: &str = r#"{
"skip_tokenizer_init": true,
"model_config": {"context_len": 2048, "vocab_size": 1000}
}"#;
/// Minimal boot config: no tokenizer load, complete `model_config` (from
/// `Default`), unified role.
fn test_server_args() -> ServerArgs {
ServerArgs {
skip_tokenizer_init: true,
..Default::default()
}
}
/// Regression: `request_shutdown` must actually stop the API server — it joins
/// the api thread once the listener closes, so the port stops accepting.
@@ -362,11 +351,11 @@ mod tests {
drop(probe);
// `skip_tokenizer_init` → no tokenizer/detok model load; minimal boot.
let server_args = ServerArgs::from_json(TEST_SERVER_ARGS).unwrap();
let server_args = test_server_args();
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
http_api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(server_args),
@@ -387,12 +376,8 @@ mod tests {
);
}
/// Regression: shutdown must return promptly even with an in-flight `/generate`.
/// No scheduler drains the ingress ring or feeds the egress ring here, so the
/// handler parks on its egress channel forever. Graceful shutdown would wait
/// for it (deadlock → only the 5s bounded-join fallback returns); the
/// non-graceful path cancels the handler via the api runtime drop, whose
/// `AbortGuard` releases the last `Senders` clone so the workers exit.
/// Regression: shutdown must return promptly even with an in-flight
/// `/generate`.
#[test]
fn shutdown_returns_with_in_flight_request() {
use std::io::Write;
@@ -402,11 +387,11 @@ mod tests {
let addr = probe.local_addr().unwrap();
drop(probe);
let server_args = ServerArgs::from_json(TEST_SERVER_ARGS).unwrap();
let server_args = test_server_args();
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
http_api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(server_args),
@@ -414,7 +399,7 @@ mod tests {
let rt = start(cfg).expect("start runtime");
// Fire a request that will block (already-tokenized → valid → pushed to the
// ring, then the handler awaits egress frames that never arrive).
// ring, then the handler awaits decode frames that never arrive).
let mut conn = std::net::TcpStream::connect(addr).expect("connect");
let body = r#"{"input_ids":[1,2,3],"stream":false,"sampling_params":{"max_new_tokens":8}}"#;
let req = format!(
@@ -447,11 +432,11 @@ mod tests {
let addr = probe.local_addr().unwrap();
drop(probe);
let server_args = ServerArgs::from_json(TEST_SERVER_ARGS).unwrap();
let server_args = test_server_args();
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
http_api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(server_args),
@@ -508,11 +493,11 @@ mod tests {
let hog = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = hog.local_addr().unwrap();
let server_args = ServerArgs::from_json(TEST_SERVER_ARGS).unwrap();
let server_args = test_server_args();
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
http_api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(server_args),
@@ -523,37 +508,4 @@ mod tests {
};
assert!(err.contains("bind"), "error should mention bind: {err}");
}
/// `server_args` missing a mandatory `model_config` field must be a startup
/// ERROR, not a panic.
///
/// `Limits::try_from` is fallible and the ingress loop is built inside a
/// `spawn_pool` closure, so resolving it there would put the failure on a
/// freshly spawned worker thread — a thread `start` never inspects. The boot
/// would report success and the server would accept connections with no
/// ingress loop behind them, hanging every request instead of refusing to
/// start. Only `Server::start` runs `validate_mandatory`, so `start` cannot
/// assume these fields are present.
#[test]
fn start_fails_when_model_config_is_incomplete() {
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = probe.local_addr().unwrap();
drop(probe);
// Boots fine in every other respect — only `model_config` is absent.
let server_args = ServerArgs::from_json(r#"{"skip_tokenizer_init": true}"#).unwrap();
let cfg = RuntimeConfig {
rust_server_args: RustServerServerArgs {
http_addr: addr,
api_worker_num: 1,
..Default::default()
},
server_args: Arc::new(server_args),
};
let err = match start(cfg) {
Ok(_) => panic!("an incomplete model_config must not boot, got Ok"),
Err(e) => e,
};
assert!(err.contains("ingress limits"), "{err}");
}
}
@@ -8,29 +8,22 @@
//! 3. one [`spawn_pool`] (N pinned workers) or [`spawn_stage`] (singleton) call.
use std::thread::JoinHandle;
use std::time::Duration;
use core_affinity::CoreId;
use super::{Runnable, RuntimeConfig};
use super::runtime::Runnable;
use crate::message::config::RuntimeConfig;
/// Cores reserved for the two TokenizerManager router threads (`tm-ingress`,
/// `tm-egress`) — light, latency-sensitive channel routers, so one core each.
/// Cores reserved for the two TokenizerManager router threads (`to-scheduler`,
/// `from-scheduler`) — light, latency-sensitive channel routers, so one core each.
///
/// TODO(tm-scaling): both TM threads are single-consumer serialization points,
/// each with its own ceiling. `tm-ingress` runs validate + `normalize_sampling_params`
/// each with its own ceiling. `to-scheduler` runs validate + `normalize_sampling_params`
/// for *every* request before fanning out to the (pooled) tokenizer workers, so a
/// high request-arrival / short-request workload is bounded by that one thread's
/// per-request cost (kept O(fields), see `sampling::normalize_sampling_params`).
/// Sharding ingress by rid — like the tokenizer/detok pools — lifts that ceiling.
///
/// `tm-egress` is a head-of-line ceiling of a different kind — it
/// does a *blocking* send per chunk to the owning detok shard, so one slow shard
/// stalls the dispatcher and thus every shard (see `Egress::route`). Sharding the
/// dispatcher alone doesn't fix it: each egress-ring frame is a whole batch fanned
/// to *all* shards, so any dispatcher still blocks on the slow one. The real fix
/// is a per-shard egress ring (the scheduler pushing each request's output to its
/// shard's ring), each drained by its own dispatcher — at which point this needs
/// one core per ingress/egress shard rather than a fixed 2.
/// Sharding to-scheduler by rid — like the tokenizer/detok pools — lifts that ceiling.
const TM_CORES: usize = 2;
/// Partition the machine's cores into four disjoint sets: the I/O-bound API
@@ -54,7 +47,7 @@ pub(super) fn plan_cores(cfg: &RuntimeConfig) -> Option<CorePlan> {
_ => return None,
};
if cores.len()
< cfg.rust_server_args.api_worker_num
< cfg.rust_server_args.http_api_worker_num
+ cfg.server_args.tokenizer_worker_num
+ cfg.server_args.detokenizer_worker_num
{
@@ -67,7 +60,7 @@ pub(super) fn plan_cores(cfg: &RuntimeConfig) -> Option<CorePlan> {
let mut it = cores.into_iter();
let api: Vec<CoreId> = it
.by_ref()
.take(cfg.rust_server_args.api_worker_num)
.take(cfg.rust_server_args.http_api_worker_num)
.collect();
let tok = it
.by_ref()
@@ -140,3 +133,18 @@ pub(super) fn spawn_pool<R, F>(
spawn_stage(&format!("{name}-{i}"), core, build(i), threads);
}
}
/// Join every handle, giving up after `timeout`.
pub(super) fn join_all_with_timeout(handles: Vec<JoinHandle<()>>, timeout: Duration) -> bool {
if handles.is_empty() {
return true;
}
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
for h in handles {
let _ = h.join();
}
let _ = done_tx.send(());
});
done_rx.recv_timeout(timeout).is_ok()
}
@@ -0,0 +1,58 @@
"""Run the `rust/` Cargo workspace's unit tests from the CPU CI suite."""
import shutil
import subprocess
import unittest
from pathlib import Path
from sglang.srt.environ import envs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
BUILD_AND_RUN_TIMEOUT_S = 900
RUST_WORKSPACE = Path(__file__).resolve().parents[3] / "rust"
register_cpu_ci(est_time=900, suite="base-a-test-cpu")
# Exported by _pr-test-stage-cpu.yml as the negation of the check-changes
# rust_workspace paths filter; it defaults to false, so only a CI run that
# positively detected no rust/ changes skips the cargo build.
@unittest.skipIf(
envs.SGLANG_SKIP_RUST_TESTS.get(),
"SGLANG_SKIP_RUST_TESTS is set (no rust/ workspace changes per CI check-changes)",
)
class TestCargoWorkspace(CustomTestCase):
def test_cargo_test_workspace(self):
# Not skipUnless: cargo is a hard dependency of the editable install
# (setuptools-rust builds sglang-grpc), so a missing toolchain is a
# broken environment, and a silently-skipped CI test is worthless.
self.assertIsNotNone(
shutil.which("cargo"),
"cargo not found on PATH; install a Rust toolchain "
"(scripts/ci/utils/install_rust_protoc.sh)",
)
self.assertTrue(
(RUST_WORKSPACE / "Cargo.toml").is_file(),
f"rust workspace manifest not found at {RUST_WORKSPACE}",
)
proc = subprocess.run(
["cargo", "test", "--workspace"],
cwd=RUST_WORKSPACE,
capture_output=True,
text=True,
timeout=BUILD_AND_RUN_TIMEOUT_S,
)
# Print unconditionally so a green run still shows which tests ran.
print(proc.stdout)
self.assertEqual(
proc.returncode,
0,
f"`cargo test --workspace` failed in {RUST_WORKSPACE}\n"
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}",
)
if __name__ == "__main__":
unittest.main()
@@ -82,7 +82,7 @@ class TestQwenE2eParity(CustomTestCase):
ids, features, grids, hashes, offsets, mrope, delta = DRIVER(
PROMPT_PER_IMAGE * len(sources), sources, spec.rust_json()
)
# The shape of Rust's MmHandoff, inline transport (test_build_native_mm
# The shape of Rust's MmEncodeResult, inline transport (test_build_native_mm
# pins the shm shape).
handoff = SimpleNamespace(
features=features,
@@ -52,7 +52,7 @@ class TestBuildNativeMm(CustomTestCase):
features = np.arange(30, dtype=np.float32)
output = NativeMmHost.build_native_mm(
self.spec,
SimpleNamespace( # the shape of Rust's MmHandoff
SimpleNamespace( # the shape of Rust's MmEncodeResult
grids=self.GRIDS,
hashes=self.HASHES,
offsets=self.OFFSETS,