[mm] rust-server: native multimodal processing for Qwen VL (integrate sglang-mm, e2e) (#32365)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kan Wu
2026-08-05 23:35:50 -07:00
committed by GitHub
co-authored by Claude Fable 5 Cursor
parent dea07b348b
commit 32e5d788bd
35 changed files with 3356 additions and 178 deletions
+11
View File
@@ -43,14 +43,25 @@ dynamo-parsers = "7.0.1"
dynamo-protocols = "5.1.0"
dynamo-renderer = "5.0.0"
flume = "0.12.0"
# Safe POD slice casts (feature buffers viewed as bytes for the shm copy).
bytemuck = "1"
itertools = "0.14"
hf-hub = { version = "0.4", default-features = false }
# POSIX shm for the MM feature fan-out (`mm::ShmSegment`).
libc = "0.2"
# Same major as the workspace pyo3: the zero-copy MM drain (`take_mm`) moves
# Rust vectors into numpy arrays.
numpy = "0.29.0"
rmp-serde = "1"
rmpv = { version = "1", features = ["with-serde"] }
# Pinned EXACTLY: this crate's accepted grammar defines the
# "anything Rust admits, Python can compile" invariant in `message::sampling`.
# A minor bump can widen it and silently reopen a scheduler-killing hole.
regex-syntax = "=0.8.11"
# The pure-Rust core of the MM pipeline. `default-features = false` drops the
# pyo3 bindings so it links as a plain rlib; renamed so `use sglang_mm::…` reads
# naturally while the crate keeps its own artifact name in the shared target/.
sglang_mm = { package = "sglang-mm", path = "../sglang-mm", default-features = false }
[dev-dependencies]
# `Router::oneshot` for handler-level router tests.
+1
View File
@@ -10,6 +10,7 @@ mod log;
mod native_api;
mod openai;
mod pd_bootstrap;
mod prefetch;
mod submit;
use std::sync::Arc;
@@ -154,7 +154,7 @@ async fn generate(
let stream = body.stream;
// Fan `text`/`input_ids`/`sampling_params` (scalar or list) into per-request
// payloads. `is_batch` = list form → the response is a JSON array.
let (payloads, is_batch) = match body.into_requests() {
let (mut payloads, is_batch) = match body.into_requests() {
Ok(v) => v,
// The error carries its own status (a bad batch is `Validation` → 400).
Err(e) => {
@@ -162,6 +162,11 @@ async fn generate(
return pre_submit_error(code, &e.to_string(), stream);
}
};
// Media I/O (URL downloads, file reads) happens here, on the API runtime
// — never on the MM worker pool (see `prefetch`).
if let Err(e) = super::prefetch::prefetch_all(&mut payloads).await {
return pre_submit_error(StatusCode::BAD_REQUEST, &e, stream);
}
if !is_batch {
// `into_requests` guarantees exactly one payload for a non-batch body.
let payload = payloads
@@ -0,0 +1,199 @@
//! Resolve I/O-backed media sources on the API runtime, before MM dispatch.
//!
//! The MM worker pool is fixed, core-pinned CPU capacity: a slow image host — or
//! a file on a hanging network mount — must never occupy it, and a request's
//! 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.
use std::sync::Arc;
use bytes::Bytes;
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};
/// Global bound on concurrent media fetches across all in-flight requests;
/// excess acquisitions queue on the semaphore without holding a thread.
static PERMITS: Semaphore = Semaphore::const_new(32);
/// Fill [`MmData::prefetched`] for every request, all fetches across the batch
/// concurrent. Any failure rejects the call (a 400, as on the Python path).
///
/// The driver's budgets ([`MAX_ITEMS_PER_REQUEST`], [`MAX_REQUEST_BYTES`]) are
/// enforced *here* rather than in `sglang_mm::driver::process`, where 64 sources
/// of 64 MiB would already be resident. The driver keeps its own checks as the
/// backstop for callers without a prefetch layer.
pub async fn prefetch_all(requests: &mut [GenerateRequest]) -> Result<(), String> {
// The item budget rejects before a single byte is fetched.
let plan = |mm: &Option<Box<MmData>>| -> Result<Vec<String>, String> {
let Some(image_data) = mm.as_deref().and_then(|m| m.image_data.as_ref()) else {
return Ok(Vec::new());
};
if item_count(image_data) > MAX_ITEMS_PER_REQUEST {
return Err(format!(
"multimodal request exceeds {MAX_ITEMS_PER_REQUEST} media items"
));
}
Ok(io_sources(image_data))
};
let plans = requests
.iter()
.map(|r| plan(&r.mm))
.collect::<Result<Vec<_>, String>>()?;
let fetches = plans
.into_iter()
.map(|sources| fetch_ordered(sources, MAX_REQUEST_BYTES));
let fetched = futures::future::try_join_all(fetches).await?;
for (req, bytes) in requests.iter_mut().zip(fetched) {
if !bytes.is_empty() {
req.mm.as_mut().expect("sources came from mm").prefetched = bytes;
}
}
Ok(())
}
/// Resolve one request's sources concurrently (globally bounded), in order,
/// against one shared `total_bytes` allowance. Overflow rejects mid-download and
/// `try_join_all` drops the rest, so queued sources never start.
async fn fetch_ordered(sources: Vec<String>, total_bytes: u64) -> Result<Vec<Bytes>, String> {
let budget = Arc::new(ByteBudget::new(total_bytes));
futures::future::try_join_all(sources.into_iter().map(|src| {
let budget = Arc::clone(&budget);
async move {
let _permit = PERMITS.acquire().await.expect("semaphore never closed");
// Blocking I/O: parks a lazily-spawned blocking-pool thread, never
// an API worker. Those threads are pinned round-robin over the api
// core set (see `on_thread_start` in `runtime::start`) — off the
// CPU-bound stages, and mostly I/O-parked, so sharing is fine.
tokio::task::spawn_blocking(move || fetch_bytes_budgeted(&src, &budget))
.await
.map_err(|e| format!("media prefetch: {e}"))?
.map(Bytes::from)
}
}))
.await
}
#[cfg(test)]
mod tests {
use rmpv::Value;
use super::*;
fn serve(bodies: Vec<Vec<u8>>) -> std::net::SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for body in bodies {
use std::io::{BufRead, Write};
let (stream, _) = listener.accept().unwrap();
let mut reader = std::io::BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).unwrap() > 2 {
line.clear(); // headers until the blank line
}
let mut stream = reader.into_inner();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(&body).unwrap();
}
});
addr
}
fn mm_request(image_data: Value) -> GenerateRequest {
GenerateRequest {
mm: Some(Box::new(MmData {
image_data: Some(image_data),
..Default::default()
})),
..Default::default()
}
}
/// URLs and file paths resolve concurrently into `prefetched` in source
/// order; CPU-only sources and mm-free requests are untouched.
#[tokio::test]
async fn resolves_io_sources() {
let addr = serve(vec![b"one".to_vec(), b"two".to_vec()]);
let path = std::env::temp_dir().join(format!("sglang-prefetch-{}", std::process::id()));
std::fs::write(&path, b"zzz").unwrap();
let mut requests = vec![
mm_request(Value::Array(vec![
Value::from(format!("http://{addr}/a.png")),
Value::from("data:image/png;base64,x"),
Value::from(format!("http://{addr}/b.png")),
Value::from(path.display().to_string()),
])),
GenerateRequest::default(),
];
prefetch_all(&mut requests).await.unwrap();
std::fs::remove_file(&path).ok();
let fetched = &requests[0].mm.as_ref().unwrap().prefetched;
// The one-shot server answers in accept order, so contents may swap
// between the two URLs; all three bodies must arrive.
let mut got: Vec<&[u8]> = fetched.iter().map(|b| b.as_ref()).collect();
got.sort();
assert_eq!(got, vec![b"one".as_ref(), b"two".as_ref(), b"zzz".as_ref()]);
assert!(requests[1].mm.is_none());
}
#[tokio::test]
async fn failed_download_rejects() {
let mut requests = vec![mm_request(Value::from("http://127.0.0.1:1/nope.png"))];
let err = prefetch_all(&mut requests).await.err().unwrap();
assert!(err.contains("media fetch"), "{err}");
}
/// The item budget rejects before any source is touched: all of these would
/// fail to fetch, so a fetch error would prove fetching started.
#[tokio::test]
async fn item_budget_rejects_before_fetching() {
let sources: Vec<Value> = (0..=MAX_ITEMS_PER_REQUEST)
.map(|i| Value::from(format!("/definitely/not/here-{i}.png")))
.collect();
let mut requests = vec![mm_request(Value::Array(sources))];
let err = prefetch_all(&mut requests).await.err().unwrap();
assert_eq!(
err,
format!("multimodal request exceeds {MAX_ITEMS_PER_REQUEST} media items")
);
assert!(requests[0].mm.as_ref().unwrap().prefetched.is_empty());
}
/// Sources legal alone but collectively over the limit are rejected while
/// downloading, not once every body is resident.
#[tokio::test]
async fn byte_budget_is_shared_across_sources() {
let addr = serve(vec![vec![b'a'; 4096], vec![b'b'; 4096]]);
let sources = vec![
format!("http://{addr}/a.png"),
format!("http://{addr}/b.png"),
];
// Room for one body, not both.
let err = fetch_ordered(sources, 6144).await.err().unwrap();
assert!(err.contains("request media byte budget"), "{err}");
}
/// ...and a fitting set still fetches: the budget never over-rejects.
#[tokio::test]
async fn byte_budget_admits_a_fitting_request() {
let addr = serve(vec![vec![b'a'; 4096], vec![b'b'; 4096]]);
let sources = vec![
format!("http://{addr}/a.png"),
format!("http://{addr}/b.png"),
];
let fetched = fetch_ordered(sources, MAX_REQUEST_BYTES).await.unwrap();
assert_eq!(fetched.iter().map(|b| b.len()).sum::<usize>(), 8192);
}
}
+7 -6
View File
@@ -14,8 +14,6 @@
use crate::error::Error;
// `Failed(Error)` carries the cause for observability even where it isn't read
// back yet; `EncodeDone` belongs to the deferred Encoder edge.
#[derive(Debug, Clone)]
pub enum RequestState {
Received,
@@ -41,8 +39,8 @@ pub enum RequestState {
/// Outcome of validation, selecting the ingress branch.
#[derive(Debug, Clone, Copy)]
pub enum ValidationOutcome {
/// Has multimodal inputs → Encoding. Deferred: no encoder yet.
#[allow(dead_code)]
/// Has multimodal inputs → Encoding, where an MM worker runs the native
/// pipeline and returns the final expanded `input_ids`.
HasMultimodal,
/// Plain text → Tokenizing.
NeedsTokenize,
@@ -52,7 +50,6 @@ pub enum ValidationOutcome {
/// Events that drive transitions. Each variant maps 1:1 to an edge in the
/// design's transition table.
#[allow(dead_code)] // EncodeDone is the deferred Encoder edge.
#[derive(Debug)]
pub enum Event {
// --- ingress ---
@@ -125,7 +122,11 @@ impl RequestState {
(Normalizing, Validated(HasMultimodal)) => Encoding,
(Normalizing, Validated(NeedsTokenize)) => Tokenizing,
(Normalizing, Validated(AlreadyTokenized)) => PreSendValidating,
(Encoding, EncodeDone) => Tokenizing,
// The MM worker returns the *final* placeholder-expanded input_ids,
// so an encoded request skips the tokenizer pool — but not the
// 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
// run exactly once per request no matter how it got its ids.
(Tokenizing, TokenizeDone) => PreSendValidating,
+65
View File
@@ -17,6 +17,7 @@ mod error;
mod fsm;
mod ids;
mod message;
mod mm;
mod ring;
mod runtime;
mod tokenizer;
@@ -31,6 +32,21 @@ use pyo3::types::PyBytes;
use crate::runtime::{Runtime, RuntimeConfig};
/// 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`.
#[pyclass(frozen, get_all)]
struct MmHandoff {
features: Option<Py<numpy::PyArray1<f32>>>,
shm_names: Option<Vec<String>>,
grids: Vec<(u32, u32, u32)>,
hashes: Vec<u64>,
offsets: Vec<(u32, u32)>,
mrope: Py<numpy::PyArray1<i64>>,
mrope_delta: i64,
}
/// Columnar ingress batch handed to Python by [`Server::recv_requests`].
/// `frozen`: immutable snapshot, so field access never contends on a borrow.
#[pyclass(frozen, get_all)]
@@ -199,6 +215,54 @@ impl Server {
self.push_frame(py, crate::message::frame_egress_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,
self.rt.tokenizer.clone(),
self.rt.mm_sidecar.clone(),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
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.
///
/// 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> {
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),
// 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) => (
None,
Some(segments.into_iter().map(|s| s.into_name()).collect()),
),
};
Some(MmHandoff {
features,
shm_names,
grids: res.grids.iter().map(|g| (g[0], g[1], g[2])).collect(),
hashes: res.hashes,
offsets: res.offsets,
mrope: res.mrope.into_pyarray(py).unbind(),
mrope_delta: res.mrope_delta,
})
}
/// Signal all threads to stop (best effort).
fn shutdown(&self) {
self.rt.request_shutdown();
@@ -245,5 +309,6 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
.try_init();
m.add_class::<Server>()?;
m.add_class::<IngressBatch>()?;
m.add_class::<MmHandoff>()?;
Ok(())
}
+5 -1
View File
@@ -11,6 +11,7 @@
mod egress;
mod finish_reason;
mod io_struct;
pub mod mm_payload;
mod request;
mod sampling;
mod types;
@@ -22,7 +23,10 @@ pub use egress::{
};
pub use finish_reason::Matched;
pub(crate) use io_struct::{AbortReq, ControlRequest, GetInternalStateReq};
pub use request::{GenerateBody, GenerateRequest, RequestKind};
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};
@@ -0,0 +1,222 @@
//! Convert a parked request's [`MmWorkItem`] into the typed [`MmInput`] the
//! `sglang-mm` driver consumes — an in-process handoff, nothing serialized.
//!
//! Every `Err` rejects the request back to the client; the message says whether
//! the input is malformed or merely outside the pipeline's scope (video/audio,
//! precomputed features, …).
use bytes::Bytes;
use rmpv::Value;
use sglang_mm::driver::{ImageSource, MmInput};
use super::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
/// on the fixed MM worker pool (see `api_server::prefetch`). `data:` and bare
/// base64 are pure CPU and stay on the worker. Lives next to `collect_images` so
/// the prefetch walk and the parse walk cannot drift.
pub fn is_io_source(src: &str) -> bool {
src.starts_with("http://")
|| src.starts_with("https://")
|| src.starts_with("file://")
|| src.starts_with('/')
}
/// The I/O-backed sources of an `image_data` value, in `collect_images` order.
pub fn io_sources(value: &Value) -> Vec<String> {
let mut out = Vec::new();
let mut walk = |value: &Value| {
if let Some(src) = value.as_str().filter(|s| is_io_source(s)) {
out.push(src.to_owned());
}
};
if let Value::Array(values) = value {
values.iter().for_each(&mut walk);
} else {
walk(value);
}
out
}
/// How many media items an `image_data` value contributes, walked the way
/// [`collect_images`] walks it, so the item budget can reject before fetching.
pub fn item_count(value: &Value) -> usize {
match value {
Value::Nil => 0,
Value::Array(values) => values.iter().map(item_count).sum(),
_ => 1,
}
}
/// I/O-backed sources are swapped for their `work.prefetched` bytes (in
/// [`io_sources`] order); one left without an entry is an internal error here,
/// never a fetch.
pub fn to_mm_input(work: MmWorkItem) -> Result<MmInput, String> {
let present = |v: &Option<Value>| v.as_ref().is_some_and(value_present);
if present(&work.video_data) || present(&work.audio_data) {
return Err("unsupported modality: video/audio input".into());
}
let mut images = Vec::new();
if let Some(image_data) = &work.image_data {
collect_images(image_data, &mut work.prefetched.iter(), &mut images)?;
}
if images.is_empty() {
return Err("no raw image sources in mm input".into());
}
Ok(MmInput {
text: work.text,
input_ids: work.input_ids,
images,
})
}
fn collect_images(
value: &Value,
prefetched: &mut std::slice::Iter<Bytes>,
out: &mut Vec<ImageSource>,
) -> Result<(), String> {
match value {
Value::Nil => Ok(()),
Value::String(value) => {
let value = value
.as_str()
.ok_or_else(|| "non-utf8 image source".to_string())?;
if is_io_source(value) {
let bytes = prefetched
.next()
.ok_or_else(|| "I/O-backed image source was not prefetched".to_string())?;
out.push(ImageSource::Bytes(bytes.to_vec()));
} else {
out.push(ImageSource::String(value.to_owned()));
}
Ok(())
}
Value::Binary(value) => {
out.push(ImageSource::Bytes(value.clone()));
Ok(())
}
Value::Array(values) => {
for value in values {
match value {
Value::String(_) | Value::Binary(_) | Value::Nil => {
collect_images(value, prefetched, out)?
}
_ => {
return Err("unsupported image_data shape: nested/typed item".into());
}
}
}
Ok(())
}
_ => Err("unsupported image_data shape".into()),
}
}
/// 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.
pub fn value_present(value: &Value) -> bool {
match value {
Value::Nil => false,
Value::Array(values) => values.iter().any(value_present),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn image_work(image: Value) -> MmWorkItem {
MmWorkItem {
text: Some("prompt".into()),
image_data: Some(image),
..Default::default()
}
}
#[test]
fn converts_string_and_list_images() {
let one = to_mm_input(image_work(Value::from("data:image/png;base64,x"))).unwrap();
assert_eq!(one.images.len(), 1);
let many = to_mm_input(image_work(Value::Array(vec![
Value::from("a"),
Value::from("b"),
])))
.unwrap();
assert_eq!(many.images.len(), 2);
}
#[test]
fn unsupported_modalities_and_shapes_rejected() {
let video = MmWorkItem {
video_data: Some(Value::from("video.mp4")),
..Default::default()
};
assert!(to_mm_input(video).err().unwrap().contains("video/audio"));
let dict = Value::Map(vec![(Value::from("format"), Value::from("x"))]);
assert!(
to_mm_input(image_work(Value::Array(vec![dict])))
.err()
.unwrap()
.contains("image_data shape")
);
}
#[test]
fn empty_video_audio_lists_are_not_modalities() {
// Mirrors Python `has_valid_data`: nil / empty lists don't count.
let work = MmWorkItem {
input_ids: Some(vec![1]),
image_data: Some(Value::from("a")),
video_data: Some(Value::Array(vec![])),
audio_data: Some(Value::Array(vec![Value::Array(vec![])])),
..Default::default()
};
assert_eq!(to_mm_input(work).unwrap().images.len(), 1);
}
/// I/O-backed sources (URLs, file paths) take their prefetched bytes in walk
/// order; one left unfetched errors, so no I/O can reach an MM worker.
#[test]
fn io_sources_use_prefetched_bytes() {
let image = Value::Array(vec![
Value::from("http://a/x.png"),
Value::from("data:image/png;base64,x"),
Value::from("/mnt/nfs/y.png"),
]);
assert_eq!(io_sources(&image), vec!["http://a/x.png", "/mnt/nfs/y.png"]);
let mut work = image_work(image.clone());
work.prefetched = vec![Bytes::from_static(b"aa"), Bytes::from_static(b"bb")];
let input = to_mm_input(work).unwrap();
let as_bytes = |i: usize| match &input.images[i] {
ImageSource::Bytes(b) => b.as_slice(),
other => panic!("expected bytes, got {other:?}"),
};
assert_eq!(as_bytes(0), b"aa");
assert_eq!(as_bytes(2), b"bb");
assert!(matches!(&input.images[1], ImageSource::String(_)));
let err = to_mm_input(image_work(image)).err().unwrap();
assert!(err.contains("not prefetched"), "{err}");
}
#[test]
fn image_free_work_rejected() {
assert!(
to_mm_input(image_work(Value::Nil))
.err()
.unwrap()
.contains("no raw image sources")
);
assert!(
to_mm_input(MmWorkItem::default())
.err()
.unwrap()
.contains("no raw image sources")
);
}
}
+328 -7
View File
@@ -106,6 +106,23 @@ pub struct GenerateBody {
pub routed_dp_rank: Option<i64>,
#[serde(default)]
pub disagg_prefill_dp_rank: Option<i64>,
// Multimodal inputs, permissive `Value` so any shape Python's
// `GenerateReqInput` accepts (URL / base64 / list / list-of-lists) parses.
// `into_requests` fans them out per the Python
// `_normalize_{image,video,audio}_data` batch rules.
#[serde(default)]
pub image_data: Option<rmpv::Value>,
/// Caller-supplied per-item content hashes (hex) overriding the computed
/// ones, so an external router's keys align with the prefix cache. Single
/// requests only: Python declares the batched shapes but `__getitem__` never
/// forwards them, so a batch is rejected here rather than answered with
/// hashes it did not ask for.
#[serde(default)]
pub mm_hashes: Option<rmpv::Value>,
#[serde(default)]
pub video_data: Option<rmpv::Value>,
#[serde(default)]
pub audio_data: Option<rmpv::Value>,
}
impl GenerateBody {
@@ -135,6 +152,10 @@ impl GenerateBody {
decode_tp_size,
routed_dp_rank,
disagg_prefill_dp_rank,
image_data,
video_data,
audio_data,
mm_hashes,
// Unported `GenerateReqInput` fields land here and are dropped, as they
// are on the Python path.
..
@@ -320,10 +341,26 @@ impl GenerateBody {
let bootstrap_pair_keys =
flatten_column(fan_out(bootstrap_pair_key, n, "bootstrap_pair_key")?);
let decode_tp_sizes = flatten_column(fan_out(decode_tp_size, n, "decode_tp_size")?);
// `mm_hashes` has no batch form: honoring it only here would give the two
// servers different prefix-cache keys for the same body. Reject instead of
// dropping it silently as Python does — the field exists to align a
// caller's keys, so ignoring it returns subtly wrong ones.
if is_batch && mm_value_present(&mm_hashes) {
return Err(Error::Validation(
"mm_hashes is not supported for batch requests; send one request per prompt".into(),
));
}
// Multimodal columns; see `split_mm_column` for the Python parity rules.
let images = split_mm_column(image_data, n, is_batch, MmBroadcast::WrapInList)
.map_err(|e| Error::Validation(format!("image_data: {e}")))?;
let videos = split_mm_column(video_data, n, is_batch, MmBroadcast::AsIs)
.map_err(|e| Error::Validation(format!("video_data: {e}")))?;
let audios = split_mm_column(audio_data, n, is_batch, MmBroadcast::AsIs)
.map_err(|e| Error::Validation(format!("audio_data: {e}")))?;
// Every column above is exactly `n` long, so zip them by value: each
// request takes ownership of its cell, with no indexing or bounds checks.
let requests = izip!(
let mut requests: Vec<GenerateRequest> = izip!(
rids,
texts,
id_lists,
@@ -338,6 +375,9 @@ impl GenerateBody {
bootstrap_rooms,
bootstrap_pair_keys,
decode_tp_sizes,
images,
videos,
audios,
)
.map(
|(
@@ -355,6 +395,9 @@ impl GenerateBody {
bootstrap_room,
bootstrap_pair_key,
decode_tp_size,
image_data,
video_data,
audio_data,
)| GenerateRequest {
rid,
text,
@@ -381,13 +424,126 @@ impl GenerateBody {
decode_tp_size,
routed_dp_rank,
disagg_prefill_dp_rank,
mm: pack_mm(image_data, video_data, audio_data),
},
)
.collect();
// Single requests only (batches rejected above). Malformed entries are
// dropped here and warned about in `mm::apply_caller_hashes`, never a 400.
if !is_batch
&& let (Some(rmpv::Value::Array(vals)), Some(req)) = (mm_hashes, requests.first_mut())
&& let Some(mm) = req.mm.as_deref_mut()
{
mm.mm_hashes = vals
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
}
Ok((requests, is_batch))
}
}
/// Box the per-item mm values, `None` when the item has none — the common
/// text-only case keeps `GenerateRequest` slim.
fn pack_mm(
image_data: Option<rmpv::Value>,
video_data: Option<rmpv::Value>,
audio_data: Option<rmpv::Value>,
) -> Option<Box<MmData>> {
if image_data.is_none() && video_data.is_none() && audio_data.is_none() {
return None;
}
Some(Box::new(MmData {
image_data,
video_data,
audio_data,
..Default::default()
}))
}
/// How a scalar mm value broadcasts across a batch: images become a one-image
/// list per item (`[[img]] * num` in Python `_normalize_image_data`),
/// video/audio broadcast bare (`[v] * num` in `_normalize_video_data`).
#[derive(Clone, Copy)]
enum MmBroadcast {
WrapInList,
AsIs,
}
/// Fan one mm field into per-item values, mirroring Python's
/// `_normalize_{image,video,audio}_data`:
/// * `None` / empty list → `None` for every item;
/// * single request → the raw value passes through (the processor wraps a
/// non-list into a one-element list);
/// * batch + scalar → broadcast to every item, per `MmBroadcast`;
/// * batch + list → per item, length must equal the batch size.
fn split_mm_column(
v: Option<rmpv::Value>,
n: usize,
is_batch: bool,
broadcast: MmBroadcast,
) -> Result<Vec<Option<rmpv::Value>>, String> {
let Some(v) = v else {
return Ok(vec![None; n]);
};
if v.is_nil() {
return Ok(vec![None; n]);
}
if !is_batch {
return Ok(vec![Some(v)]);
}
match v {
rmpv::Value::Array(items) if items.is_empty() => Ok(vec![None; n]),
rmpv::Value::Array(items) => {
if items.len() != n {
return Err(format!(
"list length {} does not match batch size {n}",
items.len()
));
}
Ok(items.into_iter().map(Some).collect())
}
scalar => {
// A broadcast deep-clones once per prompt — same blow-up as
// sampling_params above, so bound the product before any clone.
check_broadcast_budget(scalar.heap_bytes(), n, "value").map_err(|e| e.to_string())?;
Ok(match broadcast {
MmBroadcast::WrapInList => vec![Some(rmpv::Value::Array(vec![scalar])); n],
MmBroadcast::AsIs => vec![Some(scalar); n],
})
}
}
}
/// One request handed to the MM worker pool: the rid to correlate the result,
/// plus the owned inputs from [`GenerateRequest::take_mm_work`].
#[derive(Debug)]
pub struct MmRequest {
pub rid: crate::ids::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`].
#[derive(Debug, Default)]
pub struct MmWorkItem {
pub text: Option<String>,
pub input_ids: Option<Vec<i32>>,
pub image_data: Option<rmpv::Value>,
pub video_data: Option<rmpv::Value>,
pub audio_data: Option<rmpv::Value>,
/// See [`MmData::prefetched`].
pub prefetched: Vec<Bytes>,
/// See [`GenerateBody::mm_hashes`].
pub mm_hashes: Vec<String>,
}
/// 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)
}
/// Request variant — selects the ingress branch, scheduler wire message, and
/// egress shape. Each owns its body, so generate/control fields stay type-separate.
#[derive(Debug)]
@@ -475,6 +631,26 @@ pub struct GenerateRequest {
/// so these are pure passthrough for the scheduler/LB protocol.
pub routed_dp_rank: Option<i64>,
pub disagg_prefill_dp_rank: Option<i64>,
/// Multimodal inputs, carried opaquely. Consumed by the Encoding stage,
/// which ships them to the MM worker pool; never read by the tokenizer or
/// serialized onto the scheduler header. Boxed so the common text-only
/// request doesn't grow every `Request` moved between stages.
pub mm: Option<Box<MmData>>,
}
/// The opaque multimodal fields of one request (see [`GenerateRequest::mm`]).
#[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
/// never block on I/O. Out-of-band: the values above stay as the client
/// sent them.
pub prefetched: Vec<bytes::Bytes>,
/// See [`GenerateBody::mm_hashes`]; applied by the MM worker.
pub mm_hashes: Vec<String>,
}
impl GenerateRequest {
@@ -483,11 +659,33 @@ impl GenerateRequest {
self.input_ids.as_ref().is_some_and(|v| !v.is_empty())
}
/// Multimodal detection hook. Deferred (Encoder stubbed): always false until mm
/// fields are wired in.
#[allow(dead_code)]
/// True when the request carries a usable multimodal payload — the mirror of
/// Python `GenerateReqInput.contains_mm_input()`.
pub fn has_multimodal(&self) -> bool {
false
self.mm.as_ref().is_some_and(|mm| {
mm_value_present(&mm.image_data)
|| mm_value_present(&mm.video_data)
|| mm_value_present(&mm.audio_data)
})
}
/// Carve out the MM worker's inputs: `text` is cloned (the scheduler header
/// still needs it), `input_ids` is taken (the expanded ids replace it), and
/// the mm values move wholesale.
pub fn take_mm_work(&mut self) -> MmWorkItem {
let mut work = MmWorkItem {
text: self.text.clone(),
input_ids: self.input_ids.take(),
..Default::default()
};
if let Some(m) = self.mm.as_deref_mut() {
work.image_data = m.image_data.take();
work.video_data = m.video_data.take();
work.audio_data = m.audio_data.take();
work.prefetched = std::mem::take(&mut m.prefetched);
work.mm_hashes = std::mem::take(&mut m.mm_hashes);
}
work
}
pub fn encode_header(&self) -> Result<Bytes, Error> {
@@ -539,6 +737,23 @@ impl<T: HeapBytes> HeapBytes for Option<T> {
self.as_ref().map_or(0, HeapBytes::heap_bytes)
}
}
impl HeapBytes for rmpv::Value {
fn heap_bytes(&self) -> usize {
use rmpv::Value;
const NODE: usize = std::mem::size_of::<rmpv::Value>();
match self {
Value::String(s) => s.as_bytes().len(),
Value::Binary(b) => b.len(),
Value::Ext(_, b) => b.len(),
Value::Array(items) => items.iter().map(|v| NODE + v.heap_bytes()).sum(),
Value::Map(entries) => entries
.iter()
.map(|(k, v)| 2 * NODE + k.heap_bytes() + v.heap_bytes())
.sum(),
_ => 0,
}
}
}
/// Collapse `fan_out`'s nullable-element output: outer `None` (field absent /
/// scalar broadcast of nothing) and inner `None` (an explicit `null` list
@@ -743,8 +958,9 @@ mod tests {
}
/// The native `bench_serving` payload (a `GenerateReqInput` superset) parses:
/// its `lora_path`/`return_routed_experts`/`image_data` are accepted-but-ignored,
/// so `split` succeeds and drops them while the real fields survive.
/// its `lora_path`/`return_routed_experts` are accepted-but-ignored and a
/// `null` `image_data` means "no multimodal input", so `split` succeeds
/// while the real fields survive.
#[test]
fn accepts_bench_serving_payload() {
let (ps, is_batch) = requests(
@@ -758,6 +974,111 @@ mod tests {
assert_eq!(ps.len(), 1);
assert_eq!(ps[0].text.as_deref(), Some("hi"));
assert!(ps[0].stream);
assert!(!ps[0].has_multimodal());
}
/// Mm columns fan out per Python `_normalize_{image,video}_data`: a single
/// request passes the raw value through; a batch broadcasts a scalar image as
/// `[img]` per item, maps a list per item with matching lengths, and treats
/// `null`/`[]` as absent.
#[test]
fn split_mm_fanout_matches_python_normalize() {
let image_of = |p: &GenerateRequest| p.mm.as_ref().unwrap().image_data.clone().unwrap();
// Single request: raw value passes through untouched.
let (ps, _) = requests(r#"{"text": "a", "image_data": "http://x/i.jpg"}"#).unwrap();
assert_eq!(image_of(&ps[0]).as_str(), Some("http://x/i.jpg"));
assert!(ps[0].has_multimodal());
// Batch + scalar image: broadcast, wrapped as a one-image list per item.
let (ps, _) = requests(r#"{"text": ["a", "b"], "image_data": "u"}"#).unwrap();
for p in &ps {
assert_eq!(image_of(p).as_array().unwrap().len(), 1);
assert!(p.has_multimodal());
}
// Batch + per-item list: element i goes to item i.
let (ps, _) = requests(r#"{"text": ["a", "b"], "image_data": ["u1", "u2"]}"#).unwrap();
assert_eq!(image_of(&ps[0]).as_str(), Some("u1"));
assert_eq!(image_of(&ps[1]).as_str(), Some("u2"));
// Batch + wrong-length list is a 400.
assert!(requests(r#"{"text": ["a", "b"], "image_data": ["u1"]}"#).is_err());
// null / [] mean "no multimodal input".
let (ps, _) = requests(r#"{"text": "a", "image_data": null}"#).unwrap();
assert!(!ps[0].has_multimodal());
let (ps, _) = requests(r#"{"text": "a", "image_data": []}"#).unwrap();
assert!(!ps[0].has_multimodal());
// Batch + scalar video: broadcast bare (not wrapped), per Python
// `_normalize_video_data`.
let (ps, _) = requests(r#"{"text": ["a", "b"], "video_data": "v"}"#).unwrap();
let video = ps[1].mm.as_ref().unwrap().video_data.clone().unwrap();
assert_eq!(video.as_str(), Some("v"));
assert!(ps[1].has_multimodal());
}
/// A scalar broadcast is budget-checked before the deep clones (16 MiB ×
/// 4096 prompts would be 64 GiB and an abort); per-item lists clone nothing
/// and are never charged.
#[test]
fn oversized_mm_broadcast_rejected() {
let big = rmpv::Value::from("x".repeat(MAX_BROADCAST_CLONE_BYTES / 2 + 1));
let err = split_mm_column(Some(big.clone()), 2, true, MmBroadcast::WrapInList)
.err()
.unwrap();
assert!(err.contains("broadcast"), "{err}");
// A per-item list of the same total size moves, not clones: accepted.
let list = rmpv::Value::Array(vec![big, rmpv::Value::from("y")]);
assert!(split_mm_column(Some(list), 2, true, MmBroadcast::WrapInList).is_ok());
// Small scalars broadcast fine.
let small = rmpv::Value::from("u1");
assert!(split_mm_column(Some(small), 2, true, MmBroadcast::AsIs).is_ok());
}
/// `mm_hashes` rides only on single requests (Python `__getitem__`
/// parity: batches drop it) and moves into the work item.
#[test]
fn mm_hashes_single_only() {
let (mut ps, _) =
requests(r#"{"text": "a", "image_data": "u", "mm_hashes": ["a1b2", "0xff"]}"#).unwrap();
assert_eq!(ps[0].mm.as_ref().unwrap().mm_hashes, vec!["a1b2", "0xff"]);
assert_eq!(ps[0].take_mm_work().mm_hashes, vec!["a1b2", "0xff"]);
assert!(ps[0].mm.as_ref().unwrap().mm_hashes.is_empty());
// A batch cannot carry hashes (Python drops them), so it is rejected...
for body in [
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": [["x"], ["y"]]}"#,
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": ["x", "y"]}"#,
] {
let err = requests(body).err().unwrap();
assert!(matches!(err, Error::Validation(_)), "{body}: {err:?}");
}
// ...while an absent or empty field is not a payload and must still pass.
for body in [
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": null}"#,
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": []}"#,
] {
assert!(requests(body).is_ok(), "{body}");
}
}
/// `take_mm_work` clones `text` (the scheduler header still needs it) and
/// moves everything the worker owns out of the request.
#[test]
fn mm_work_item_takes_owned_fields() {
let (mut ps, _) =
requests(r#"{"text": "hi", "image_data": ["u1", "u2"], "audio_data": "a"}"#).unwrap();
let work = ps[0].take_mm_work();
assert_eq!(work.text.as_deref(), Some("hi"));
assert!(work.input_ids.is_none());
assert_eq!(work.image_data.unwrap().as_array().unwrap().len(), 2);
assert!(work.video_data.is_none());
assert_eq!(work.audio_data.unwrap().as_str(), Some("a"));
// Moved out, not cloned; `text` survives for the header.
assert!(ps[0].mm.as_ref().unwrap().image_data.is_none());
assert_eq!(ps[0].text.as_deref(), Some("hi"));
}
/// The body limit is disabled, so an unbounded batch turns a small body into an
+413
View File
@@ -0,0 +1,413 @@
//! 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(_)
));
}
}
+53 -3
View File
@@ -8,6 +8,8 @@
//! * 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
//! [`Runtime::spawn_mm_pool`] (multimodal models only)
//!
//! Keeping CPU-bound tokenize/detokenize off the async executor avoids stalling
//! axum's worker threads.
@@ -38,6 +40,18 @@ pub use runnable::Runnable;
pub struct Runtime {
pub ingress: IngressConsumer,
pub egress: EgressProducer,
/// 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>,
/// 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,
/// Worker join handles, joined by `request_shutdown` / `Drop`.
threads: Mutex<Vec<JoinHandle<()>>>,
/// The single shutdown sender.
@@ -49,6 +63,21 @@ pub struct Runtime {
const SHUTDOWN_JOIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
impl Runtime {
/// Spawn `workers` `mm-worker-{i}` threads into the shutdown join set —
/// late, once Python has built the mm spec (`Server::start_mm_workers`).
///
/// Deliberately unpinned: the threads inherit the launch thread's affinity,
/// already narrowed by `RustServer.launch` to the server cores, so bursty
/// 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>) {
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())
});
}
/// Stop the runtime and join every worker thread (with a bounded wait).
///
/// Dropping `shutdown_tx` wakes the tm-ingress/tm-egress selectors (which
@@ -107,6 +136,10 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
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);
// 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 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);
@@ -140,6 +173,14 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
cfg.server_args.revision.as_deref(),
skip_tokenizer_init,
)?;
// The `TextTokenizer` view of it, shared by the tokenizer pool and the MM
// worker path (which encodes the placeholder-expanded prompt itself).
let text_tokenizer: Option<Arc<dyn tokenizer::TextTokenizer>> = dyn_tokenizer
.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();
// --- Detokenizer shards (pinned, CPU bound) ---
{
@@ -168,10 +209,9 @@ 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.
if let Some(t) = &dyn_tokenizer {
if let Some(tokenizer) = &text_tokenizer {
// Reuse the single loaded tokenizer (shared with the detok shards).
let tokenizer: Arc<dyn tokenizer::TextTokenizer> =
Arc::new(tokenizer::DynamoTokenizer::new(t.clone()));
let tokenizer = tokenizer.clone();
let tok_cores = plan.as_ref().map(|p| p.tok.clone());
// Workers share the MPMC inbox (`tok_rx`) and the read-only backend, so
// each gets a cheap clone of both.
@@ -220,6 +260,11 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
.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 {
enabled: cfg.server_args.model_is_multimodal(),
tx: mm_tx,
sidecar: mm_sidecar.clone(),
};
let mut parts = Some((tm_rx, ingress_tx)); // moved into the single worker
let shutdown_rx = shutdown_rx.clone();
spawn_pool("tm-ingress", cores, 1, &mut threads, |_| {
@@ -230,6 +275,7 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
senders.clone(),
ingress_tx,
limits.clone(),
mm.clone(),
shutdown_rx.clone(),
)
});
@@ -282,6 +328,10 @@ pub fn start(cfg: RuntimeConfig) -> Result<Runtime, String> {
Ok(Runtime {
ingress: ingress_rx,
egress: egress_tx,
mm: mm_rx,
tm: tm_tx,
tokenizer: text_tokenizer,
mm_sidecar,
threads: Mutex::new(threads),
shutdown_tx: Mutex::new(Some(shutdown_tx)),
})
+11
View File
@@ -158,6 +158,11 @@ pub struct ModelConfig {
/// 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
@@ -259,6 +264,12 @@ impl ServerArgs {
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 {
+8 -1
View File
@@ -14,7 +14,7 @@ mod egress;
mod ingress;
pub use egress::{ActivityCounter, Egress};
pub use ingress::{Ingress, Limits};
pub use ingress::{Ingress, Limits, Mm};
use crate::ids::Rid;
use crate::message::{DetokMsg, Request};
@@ -36,6 +36,13 @@ pub enum TmEvent {
/// 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.
@@ -17,14 +17,17 @@
//! The egress edges (Streaming/Finalizing/Completed) are driven on the egress
//! side (see `egress` + `detokenizer`).
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, Request,
RequestKind,
AbortReq, ControlRequest, DetokMsg, EgressItem, GenerateRequest, IngressMsg, MmRequest,
Request, RequestKind,
};
use crate::ring::IngressProducer;
use crate::runtime::{Runnable, ServerArgs};
@@ -41,9 +44,29 @@ pub struct Ingress {
senders: Senders,
ingress: IngressProducer,
limits: Limits,
mm: Mm,
/// Requests parked in `Encoding` while an MM worker processes their media;
/// resumed by `MmEncoded` / `MmFailed`. Only this thread touches it, so no
/// lock.
pending_mm: HashMap<Rid, Request>,
shutdown: flume::Receiver<()>,
}
/// The ingress side of the MM path.
#[derive(Clone)]
pub struct Mm {
/// Whether the model is multimodal. When false, mm fields are silently
/// ignored, as the Python `TokenizerManager` does with `mm_processor is
/// None`.
pub enabled: bool,
/// → MM worker pool (spawned via `Server.start_mm_workers`).
pub tx: flume::Sender<MmRequest>,
/// 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,
}
/// 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;
@@ -104,6 +127,7 @@ impl Ingress {
senders: Senders,
ingress: IngressProducer,
limits: Limits,
mm: Mm,
shutdown: flume::Receiver<()>,
) -> Self {
Self {
@@ -112,6 +136,8 @@ impl Ingress {
senders,
ingress,
limits,
mm,
pending_mm: HashMap::new(),
shutdown,
}
}
@@ -124,7 +150,7 @@ enum Lane {
}
impl Runnable for Ingress {
fn run(self) {
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.
@@ -139,6 +165,12 @@ impl Runnable for Ingress {
Some(Lane::Event(TmEvent::Ingress(req) | TmEvent::Tokenized(req))) => {
self.drive(req)
}
Some(Lane::Event(TmEvent::MmEncoded { rid, input_ids })) => {
self.on_mm_encoded(rid, input_ids)
}
Some(Lane::Event(TmEvent::MmFailed { rid, message })) => {
self.on_mm_failed(rid, message)
}
None => {
// Shutdown, or the inbox closed. Drain whatever is still queued
// on the abort lane first: those requests are in flight on the
@@ -168,6 +200,9 @@ impl Ingress {
if err.http_status() == 500 {
tracing::error!(rid = %req.rid, error = %err, "ingress 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
if registered {
@@ -178,11 +213,12 @@ impl Ingress {
}
/// Drive a request through its ingress states until it terminates (failed or
/// pushed to the ring) or is handed to the tokenizer pool (re-entering as a
/// `Tokenized` event). Each arm acts and advances the FSM; the loop
/// re-dispatches. The arms are the design table's states, `Failed` the single
/// reject path.
fn drive(&self, mut req: Request) {
/// 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
/// the FSM; the loop re-dispatches. The arms are the design table's states,
/// `Failed` the single reject path.
fn drive(&mut self, mut req: Request) {
// Flipped once `register_detok` succeeds; `fail` must not deregister before
// that (see `fail`). A pool return re-enters `drive` already registered.
let mut registered = !matches!(req.state, RequestState::Received);
@@ -235,6 +271,13 @@ impl Ingress {
.normalize(self.limits.skip_tokenizer_init, self.limits.vocab_size)
{
Err(e) => Err(e),
// The native pipeline produces the final input_ids,
// so it wins even over a pre-tokenized prompt (which
// still needs placeholder expansion) — the same
// precedence as the Python TokenizerManager.
Ok(()) if self.mm.enabled && g.has_multimodal() => {
Ok(ValidationOutcome::HasMultimodal)
}
// Client ids skip the pool; text goes to the tokenizer.
Ok(()) if g.already_tokenized() => {
Ok(ValidationOutcome::AlreadyTokenized)
@@ -252,6 +295,40 @@ impl Ingress {
}
}
}
// Hand off to the MM worker pool and park the request; it
// re-enters via `MmEncoded` (→ PreSendValidating) or `MmFailed`
// (→ reject). Doesn't loop.
RequestState::Encoding => {
let work = {
let RequestKind::Generate(g) = &mut req.kind else {
self.fail(
&mut req,
Error::Internal("non-generate request in Encoding".into()),
registered,
);
return;
};
g.take_mm_work()
};
let msg = MmRequest {
rid: req.rid.clone(),
work,
};
// Full = the pool can't keep up, so back-pressure like a full
// ingress ring. Disconnected = pool gone.
if let Err(e) = self.mm.tx.try_send(msg) {
let err = match e {
flume::TrySendError::Full(_) => Error::QueueFull,
flume::TrySendError::Disconnected(_) => {
Error::Internal("mm worker pool gone".into())
}
};
self.fail(&mut req, err, registered);
return;
}
self.pending_mm.insert(req.rid.clone(), req);
return;
}
// Hand off to the tokenizer pool; it returns the request as a
// `Tokenized` event (PreSendValidating, or Failed on error).
// Doesn't loop.
@@ -392,6 +469,34 @@ impl Ingress {
}
}
/// An MM worker finished a parked request: fill in the final expanded
/// `input_ids`, advance `Encoding → PreSendValidating`, and resume driving
/// (pre-send checks → ring). No pending entry means the request was already
/// rejected or aborted, so the result is dropped.
fn on_mm_encoded(&mut self, rid: Rid, input_ids: Vec<i32>) {
let Some(mut req) = self.pending_mm.remove(&rid) else {
tracing::debug!(rid = %rid, "mm result for unknown/finished request; dropped");
// It will never reach the scheduler drain, so purge or leak.
self.mm.sidecar.purge(rid.as_str());
return;
};
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = Some(input_ids);
}
let _ = req.state.apply(Event::EncodeDone); // Encoding → PreSendValidating
self.drive(req);
}
/// An MM worker failed a parked request (bad URL, processor error): reject it
/// back to the client, as Python turns a per-request exception into a 400.
fn on_mm_failed(&mut self, rid: Rid, message: String) {
let Some(mut req) = self.pending_mm.remove(&rid) else {
tracing::debug!(rid = %rid, "mm failure for unknown/finished request; dropped");
return;
};
self.fail(&mut req, Error::Encode(message), true); // parked ⇒ registered
}
/// Client disconnected (or a detok terminal): deregister the sink, then push an
/// `AbortReq(rid)` so the scheduler stops generating for it.
///
@@ -400,8 +505,15 @@ impl Ingress {
/// That wastes GPU work until the request finishes on its own, but it cannot be
/// misdelivered — the rid is unique to this request for the process's lifetime
/// ([`Rid::from_client`]), so no later request can ever answer to it.
fn on_abort(&self, source: AbortSource) {
///
/// A request parked in `pending_mm` is cancelled here, so the worker's late
/// result lands in `on_mm_encoded`'s no-entry branch and purges the sidecar —
/// no generation runs for output nobody will read.
fn on_abort(&mut self, source: AbortSource) {
let rid = source.rid().clone();
if self.pending_mm.remove(&rid).is_some() {
tracing::debug!(rid = %rid, "abort cancelled request parked for MM");
}
let _ = self
.senders
.detok_for(&rid)
@@ -616,12 +728,14 @@ mod tests {
use tokio::sync::mpsc;
/// An `Ingress` plus its detok-shard receiver, ring consumer (keep alive —
/// dropping it closes the ring → false QueueFull), and tm inbox sender.
/// dropping it closes the ring → false QueueFull), tm inbox sender, and the
/// mm-pool receiver (keep alive — dropping it makes mm submits fail).
fn make_ingress() -> (
Ingress,
flume::Receiver<DetokMsg>,
IngressConsumer,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
make_ingress_with(test_limits())
}
@@ -633,6 +747,7 @@ mod tests {
flume::Receiver<DetokMsg>,
IngressConsumer,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
make_ingress_inner(test_limits(), abort_rx)
}
@@ -644,6 +759,7 @@ mod tests {
flume::Receiver<DetokMsg>,
IngressConsumer,
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
@@ -658,6 +774,7 @@ mod tests {
flume::Receiver<DetokMsg>,
IngressConsumer,
flume::Sender<TmEvent>,
flume::Receiver<MmRequest>,
) {
let (tok_tx, _tok_rx) = flume::unbounded();
let (detok_tx, detok_rx) = flume::unbounded();
@@ -669,12 +786,30 @@ mod tests {
};
let (ingress_producer, consumer) = ingress_ring(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(tm_rx, abort_rx, senders, ingress_producer, limits, sd_rx);
(ingress, detok_rx, consumer, tm_tx)
let ingress = Ingress::new(
tm_rx,
abort_rx,
senders,
ingress_producer,
limits,
test_mm(mm_tx, true),
sd_rx,
);
(ingress, detok_rx, consumer, tm_tx, mm_rx)
}
/// An [`Mm`] over `tx` with a fresh sidecar.
fn test_mm(tx: flume::Sender<MmRequest>, enabled: bool) -> Mm {
Mm {
enabled,
tx,
sidecar: Default::default(),
}
}
/// Both abort sources do the same two things: drop the detok entry so no
@@ -696,7 +831,7 @@ mod tests {
let (ingress_producer, consumer) = ingress_ring(16);
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let ingress = Ingress::new(
let mut ingress = Ingress::new(
flume::unbounded().1,
flume::unbounded().1,
Senders {
@@ -707,6 +842,7 @@ mod tests {
},
ingress_producer,
test_limits(),
test_mm(flume::unbounded().0, true),
sd_rx,
);
@@ -960,7 +1096,7 @@ 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 (ingress, detok_rx, consumer, _tm_tx) = make_ingress_with(Limits {
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress_with(Limits {
context_len: 4,
..test_limits()
});
@@ -993,7 +1129,7 @@ mod tests {
/// pins. Nothing may reach the scheduler ring.
#[test]
fn detokenize_flows_register_then_decode_and_skips_the_ring() {
let (ingress, detok_rx, consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
let (tx, mut rx) = mpsc::channel(8);
ingress.drive(Request {
rid: "41".into(),
@@ -1028,7 +1164,7 @@ mod tests {
/// leak and no decode job to drop).
#[test]
fn detokenize_negative_ids_reject_before_registration() {
let (ingress, detok_rx, consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, consumer, _tm_tx, _mm_rx) = make_ingress();
let (tx, mut rx) = mpsc::channel(8);
ingress.drive(Request {
rid: "43".into(),
@@ -1071,7 +1207,15 @@ mod tests {
let (_tm_tx, tm_rx) = flume::unbounded();
let (sd_tx, sd_rx) = flume::unbounded::<()>();
std::mem::forget(sd_tx);
let ingress = Ingress::new(tm_rx, abort_rx, senders, producer, test_limits(), sd_rx);
let mut ingress = Ingress::new(
tm_rx,
abort_rx,
senders,
producer,
test_limits(),
test_mm(flume::unbounded().0, true),
sd_rx,
);
ingress.on_abort(AbortSource::Guard("pushed".into()));
ingress.on_abort(AbortSource::Guard("dropped".into()));
@@ -1108,7 +1252,7 @@ mod tests {
#[test]
fn pre_registration_failure_does_not_deregister() {
// Rejected inside `validate` (out-of-vocab id), which runs before registration.
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
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]);
@@ -1121,7 +1265,7 @@ mod tests {
);
// A post-registration reject still deregisters (the leak fix stays fixed).
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
ingress.drive(generate_req(
42,
SamplingParams {
@@ -1140,7 +1284,7 @@ mod tests {
/// sees `Register` then `Deregister`. Regression for RSS growth on bad input.
#[test]
fn rejected_request_deregisters_from_shard() {
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
// top_p = 2.0 is outside (0, 1], so `SamplingParams::normalize` rejects it.
let bad = SamplingParams {
top_p: 2.0,
@@ -1167,7 +1311,7 @@ mod tests {
/// and kills the scheduler process (`make_ingress` bounds vocab at 1000).
#[test]
fn out_of_vocab_input_ids_rejected() {
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
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]);
@@ -1185,7 +1329,7 @@ mod tests {
/// Same guard for negative ids and for `token_ids_logprob` entries.
#[test]
fn negative_and_logprob_token_ids_rejected() {
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let mut req = generate_req(22, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.input_ids = Some(vec![-1]);
@@ -1196,7 +1340,7 @@ mod tests {
Ok(_) => panic!("negative token id must not be admitted"),
}
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
let mut req = generate_req(23, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
g.token_ids_logprob = Some(vec![999_999]);
@@ -1211,7 +1355,7 @@ mod tests {
/// A valid request is registered and handed onward — never deregistered.
#[test]
fn admitted_request_keeps_registration() {
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
// Empty map → all sampling defaults, passes normalization.
ingress.drive(generate_req(9, SamplingParams::default()));
@@ -1229,7 +1373,7 @@ mod tests {
/// path and deregistered, not leaked.
#[test]
fn tokenize_failure_deregisters_via_ingress() {
let (ingress, detok_rx, _consumer, tm_tx) = make_ingress();
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress();
// The pool marks a failed encode as `Failed(err)` before returning it.
let mut req = generate_req(11, SamplingParams::default());
let _ = req
@@ -1253,7 +1397,7 @@ 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) = make_ingress_with_abort(abort_rx);
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress_with_abort(abort_rx);
abort_tx.send(AbortSource::Guard("rid-13".into())).unwrap();
drop(abort_tx);
drop(tm_tx);
@@ -1270,7 +1414,7 @@ mod tests {
/// rejected; its registration is untouched.
#[test]
fn tokenized_return_pushes_without_deregister() {
let (ingress, detok_rx, _consumer, tm_tx) = make_ingress();
let (ingress, detok_rx, _consumer, tm_tx, _mm_rx) = make_ingress();
let mut req = generate_req(15, SamplingParams::default());
// Simulate a successful pool return: ids filled, PreSendValidating.
if let RequestKind::Generate(g) = &mut req.kind {
@@ -1293,7 +1437,7 @@ mod tests {
#[test]
fn tokenize_pool_gone_deregisters() {
// `make_ingress` drops the tok receiver, so `tok.send` fails.
let (ingress, detok_rx, _consumer, _tm_tx) = make_ingress();
let (mut ingress, detok_rx, _consumer, _tm_tx, _mm_rx) = make_ingress();
// No ids → NeedsTokenize → Tokenizing branch.
let mut req = generate_req(21, SamplingParams::default());
if let RequestKind::Generate(g) = &mut req.kind {
@@ -1311,4 +1455,157 @@ mod tests {
);
assert!(detok_rx.try_recv().is_err(), "no further shard messages");
}
/// Build a generate request carrying an image. The parked entry and the
/// `MmEncoded` resume path agree on identity via the rid string.
fn mm_generate_req(rid: &str) -> Request {
let (tx, _rx) = mpsc::channel(8);
Request {
rid: rid.to_string().into(),
state: RequestState::Received,
sink: EgressSink::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 {
image_data: Some(rmpv::Value::from("data:image/jpeg;base64,xxxx")),
..Default::default()
})),
..Default::default()
})),
}
}
/// An abort while the request is parked for MM cancels it: the pending
/// entry is removed, the worker's late result is dropped, and its parked
/// 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"));
mm_rx.try_recv().expect("parked to mm pool");
// The worker parks its result, as it always does before MmEncoded.
ingress.mm.sidecar.park(
"mm-gone".into(),
crate::mm::MmSidecarEntry {
features: crate::mm::FeatureStore::Inline(vec![]),
grids: vec![],
hashes: vec![],
offsets: vec![],
mrope: vec![],
mrope_delta: 0,
},
);
ingress.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]);
assert!(
consumer.drain(16).headers.is_empty(),
"cancelled, not queued"
);
assert!(ingress.mm.sidecar.take("mm-gone").is_none(), "entry purged");
}
/// A multimodal request parks in `Encoding` (submitted to the mm worker
/// pool, not the tokenizer pool, not the ring) until `MmEncoded` resumes
/// 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"));
// 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");
assert_eq!(sub.rid.as_str(), "mm-1");
assert_eq!(sub.work.text.as_deref(), Some("<image> hi"));
assert!(sub.work.input_ids.is_none(), "no client input_ids");
assert_eq!(
sub.work.image_data.as_ref().and_then(|v| v.as_str()),
Some("data:image/jpeg;base64,xxxx")
);
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]);
let batch = consumer.drain(16);
assert_eq!(batch.headers.len(), 1);
assert_eq!(
batch.lengths,
vec![4],
"expanded ids ride the columnar cell"
);
}
/// 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"));
assert!(
matches!(detok_rx.try_recv(), Ok(DetokMsg::Register { .. })),
"registered before parking",
);
ingress.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"),
"mm failure must deregister",
);
assert!(consumer.drain(16).headers.is_empty(), "nothing queued");
}
/// On a non-multimodal model (`Mm::enabled == false`), image_data is silently
/// ignored and the request tokenizes as plain text — the Python
/// TokenizerManager behavior when `mm_processor is None`.
#[test]
fn mm_fields_ignored_when_disabled() {
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],
};
let (ingress_producer, _consumer) = ingress_ring(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(
tm_rx,
abort_rx,
senders,
ingress_producer,
test_limits(),
test_mm(mm_tx, false),
sd_rx,
);
ingress.drive(mm_generate_req("mm-3"));
assert!(
mm_rx.try_recv().is_err(),
"mm disabled: nothing submitted to the mm channel",
);
assert!(
tok_rx.try_recv().is_ok(),
"request must fall through to plain tokenization",
);
}
/// A late mm result for a rid that is no longer parked is dropped without
/// 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());
assert!(consumer.drain(16).headers.is_empty());
}
}