add the rust server tokenizer, detokenizer, and egress modules (#32872)

This commit is contained in:
Rain Jiang
2026-07-30 12:46:17 -07:00
committed by GitHub
parent 35f2e6ab58
commit 922d6e5542
3 changed files with 976 additions and 0 deletions
+597
View File
@@ -0,0 +1,597 @@
//! Detokenizer shards — CPU-bound, one pinned thread per shard.
//!
//! Each shard owns a *local* `rid -> DetokState` map. There is no lock: a given
//! rid is routed to exactly one shard (by `Rid::shard`) for both its
//! `Register` and all its `Chunk`s, so the map has a single accessor.
//!
//! The hash PARTITIONS, the rid IDENTIFIES. Keying the map by the hash meant two
//! distinct rids that happened to collide became one entry: `Register` evicted the
//! first client's sink and their tokens were then written to the second client's
//! connection. Chunks carry the rid string (moved out of the frame header, which
//! owns it and would otherwise drop it), so a collision now only co-locates.
//!
//! Real detokenization uses dynamo-tokenizers' `DecodeStream`, a stateful
//! incremental decoder (TGI/vLLM-style: it buffers partial UTF-8 / byte-fallback
//! tokens and only emits text once a valid boundary is reached). Each request
//! gets its own `DecodeStream`. When no tokenizer is configured (or
//! `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):
//! 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;
/// 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
/// `DetokenizerBackend::new_decoder`.
const SKIP_SPECIAL_TOKENS: bool = true;
/// Per-request incremental decoder. `step` feeds the new token ids for one chunk
/// and returns the newly decoded text delta (empty if the ids only produced a
/// partial/incomplete multi-byte sequence that needs more tokens).
pub trait StreamDecoder: Send {
fn step(&mut self, token_ids: &[i32]) -> Result<String, Error>;
}
/// Real decoder wrapping a dynamo-tokenizers `DecodeStream`.
struct DynamoDecoder {
stream: dynamo_tokenizers::DecodeStream,
}
impl StreamDecoder for DynamoDecoder {
fn step(&mut self, token_ids: &[i32]) -> Result<String, Error> {
let mut out = String::new();
for &id in token_ids {
if let Some(chunk) = self
.stream
.step(id as u32)
.map_err(|e| Error::Detokenize(e.to_string()))?
{
out.push_str(&chunk);
}
}
Ok(out)
}
}
/// Shard-wide detok backend. Cloned per shard; mints a fresh per-request decoder
/// on each `Register`.
#[derive(Clone)]
pub enum DetokenizerBackend {
Dynamo(dynamo_tokenizers::Tokenizer),
/// No decoding at all — the shard emits each chunk's raw output token ids as
/// `output_ids` (no `DecodeStream`, no accumulation). Used for
/// `skip_tokenizer_init` and when no tokenizer is configured.
Skip,
}
impl DetokenizerBackend {
/// Mint a per-request decoder, or `None` in skip mode (the shard passes the
/// token ids through untouched instead of decoding text).
fn new_decoder(&self) -> Option<Box<dyn StreamDecoder>> {
match self {
// NOTE: the stream is seeded with an empty prompt context, which is
// correct for the common case. Seeding with the prompt's trailing
// tokens (for perfect first-token spacing) would require Register to
// carry input_ids — deferred.
DetokenizerBackend::Dynamo(t) => Some(Box::new(DynamoDecoder {
stream: t.decode_stream(&[], SKIP_SPECIAL_TOKENS),
})),
DetokenizerBackend::Skip => None,
}
}
/// Decode each logprob token id to its own text (one id at a time, matching
/// Python's `batch_decode([[id] for id in ids])`). Runs on this CPU-bound
/// shard, not the api-server I/O threads. `Skip` mode (no tokenizer) yields
/// no text, so the `[logprob, token_id, text]` tuple's text slot stays null.
fn decode_logprob_texts(&self, idxs: &[i32]) -> Vec<String> {
match self {
DetokenizerBackend::Dynamo(t) => idxs
.iter()
.map(|&id| {
t.decode(&[id as u32], false)
.map(String::from)
.unwrap_or_default()
})
.collect(),
DetokenizerBackend::Skip => Vec::new(),
}
}
}
struct DetokState {
sink: EgressSink,
/// `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,
/// `SamplingParams.no_stop_trim`: keep the matched stop in the output. Default
/// (`false`) trims it off the final chunk (see [`trim_stop_str`]).
no_stop_trim: bool,
/// Per-request incremental decoder; `None` in `skip_tokenizer_init` mode.
/// This is the *only* per-request accumulation the shard keeps: the decoder's
/// internal byte/UTF-8 buffer. Decoded **text deltas** are emitted per chunk
/// (no cumulative buffer here) — the api-server's drain loop reassembles the
/// 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.
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.
pub struct DetokenizerWorker {
shard: usize,
rx: flume::Receiver<DetokMsg>,
backend: DetokenizerBackend,
/// Unbounded abort lane, used to abort a request the shard had to drop
/// (client backpressure) so the scheduler stops generating for it.
abort: flume::Sender<AbortSource>,
}
impl DetokenizerWorker {
pub fn new(
shard: usize,
rx: flume::Receiver<DetokMsg>,
backend: DetokenizerBackend,
abort: flume::Sender<AbortSource>,
) -> Self {
Self {
shard,
rx,
backend,
abort,
}
}
}
impl Runnable for DetokenizerWorker {
fn run(self) {
let mut table: HashMap<Rid, DetokState> = HashMap::new();
tracing::debug!(shard = self.shard, "detokenizer worker started");
// 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.
while let Ok(msg) = self.rx.recv() {
match msg {
DetokMsg::Register {
rid,
sink,
decode_logprob_text,
no_stop_trim,
} => {
table.insert(
rid.clone(),
DetokState {
sink,
decode_logprob_text,
no_stop_trim,
decoder: self.backend.new_decoder(),
// Registered == handed to the scheduler == Queued.
fsm: RequestState::Queued,
},
);
}
// One decode step's chunks for this shard, batched by tm-egress.
DetokMsg::Chunks(evs) => {
for ev in evs {
handle_chunk(&mut table, ev, &self.backend, &self.abort);
}
}
DetokMsg::Result { rid, payload } => handle_result(&mut table, &rid, payload),
DetokMsg::Fail { rid, message } => {
handle_fail(&mut table, &rid, message, &self.abort)
}
DetokMsg::Deregister { rid } => {
table.remove(&rid);
}
}
}
}
}
/// Control-request result: deliver the JSON payload to the sink verbatim as a
/// 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
// / Finalizing states — single response, never streamed).
st.fsm = RequestState::Completed;
}
}
/// Terminal per-request failure (bad request header): send an `Error` to the sink
/// (the api-server turns it into an HTTP 400) and drop the request.
/// Terminal per-request failure. `Internal` (500), not `Validation` (400): the
/// producers of this message are server faults — a malformed scheduler output
/// frame — not bad client input. Also aborts the request on the scheduler, which
/// otherwise keeps generating tokens for a connection that will never read them.
fn handle_fail(
table: &mut HashMap<Rid, DetokState>,
rid: &Rid,
message: String,
abort: &flume::Sender<AbortSource>,
) {
if let Some(mut st) = table.remove(rid) {
// Abort first: `try_send` on the sink can release the handler, which frees
// the rid for reuse (same ordering hazard as the disconnect path).
let _ = abort.send(AbortSource::Detok(rid.clone()));
let _ = st
.sink
.try_send(EgressItem::Error(Error::Internal(message)));
st.fsm = RequestState::Completed;
}
}
fn handle_chunk(
table: &mut HashMap<Rid, DetokState>,
mut ev: ChunkEvent,
backend: &DetokenizerBackend,
abort: &flume::Sender<AbortSource>,
) {
// Copied once: `ev` is moved into the sink below, but the rid is still
// needed to look the request up and to remove it.
let rid = ev.rid.clone();
let Some(st) = table.get_mut(&rid) else {
// Late chunk after completion/abort — drop.
return;
};
let decode_logprob_text = st.decode_logprob_text;
let no_stop_trim = st.no_stop_trim;
// Queued → Streaming on the first chunk (the scheduler picked it).
if matches!(st.fsm, RequestState::Queued) {
let _ = st.fsm.apply(Event::SchedulerPicked);
}
let finished = ev.finish_reason.is_some();
// Matched-stop trim (Python `trim_matched_stop`): the final chunk's finish
// reason names the stop it matched — a stop STRING or a stop TOKEN id. By
// default that stop is removed from the output; `no_stop_trim` keeps it.
let matched = finished
.then(|| ev.finish_reason.as_ref().and_then(|fr| fr.matched()))
.flatten()
.cloned();
// Count generated tokens (incl. a matched stop token) *before* trimming.
let n_tok = ev.token_ids.len() as u64;
// Stop TOKEN: drop it before decode, so it reaches neither `text` nor `output_ids`.
trim_stop_token(&mut ev.token_ids, &matched, no_stop_trim);
// Fully incremental: decode just this chunk's delta. `token_ids` stays in the
// event — it's ALSO surfaced as the `/generate` response's `output_ids` (the
// Python server returns them by default alongside `text`), in both normal and
// `skip_tokenizer_init` mode. Nothing cumulative is kept here — the api-server's
// drain loop reassembles it where needed.
let mut delta_text = match &mut st.decoder {
Some(decoder) => match decoder.step(&ev.token_ids) {
Ok(delta) => delta,
Err(e) => {
// Abort too: this is terminal for the request, and without it the
// scheduler keeps generating for a connection that is already gone
// — 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));
table.remove(&rid);
return;
}
},
// skip_tokenizer_init: no decode; the token ids pass through in `ev`.
None => String::new(),
};
// Stop STRING: trim it (and anything after) from the decoded delta's tail.
if let Some(Matched::Str(stop)) = &matched {
trim_stop_str(&mut delta_text, stop, no_stop_trim);
}
// Streaming → Streaming (finish:false) or Streaming → Finalizing (finish:true).
let _ = st.fsm.apply(Event::Chunk { finish: finished });
// `return_text_in_logprobs`: decode each logprob token id to text HERE (this
// CPU-bound shard) rather than on the api-server I/O threads. Flat text columns
// stay parallel to the `idx` buffers, so `sglang_frame` just reads them. Only the
// logprob-carrying frames have an `extras` box; a plain token frame skips this.
if decode_logprob_text && let Some(ex) = ev.extras.as_deref_mut() {
ex.out_lp_txt = backend.decode_logprob_texts(&ex.out_lp_idx);
ex.in_lp_txt = backend.decode_logprob_texts(&ex.in_lp_idx);
ex.out_top_txt = backend.decode_logprob_texts(&ex.out_top_idx);
ex.in_top_txt = backend.decode_logprob_texts(&ex.in_top_idx);
ex.out_tid_txt = backend.decode_logprob_texts(&ex.out_tid_idx);
ex.in_tid_txt = backend.decode_logprob_texts(&ex.in_tid_idx);
}
// Fill the decode outputs in place; the pre-decode columns (boxed logprobs/hidden,
// token_ids, prompt_tokens, finish_reason) already ride in `ev`. The API handler
// formats this delta (and accumulates for the cumulative view).
ev.text = delta_text;
ev.completion_tokens = n_tok;
if finished {
// The Done frame *is* the final frame: Finalizing → Completed.
let sent = st.sink.try_send(EgressItem::Done(ev)).is_ok();
let _ = st.fsm.apply(if sent {
Event::FinalFrameSent
} else {
Event::Disconnect
});
table.remove(&rid);
} else {
// Every intermediate chunk emits its delta frame. A failed send means the
// client can't receive it — `Closed` (gone) or `Full` (backpressure: not
// reading fast enough). Either way we can't buffer unboundedly, and
// 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)) {
match e {
SinkError::Full => {
tracing::warn!(
rid = %rid,
"detok: sink full; aborting (client backpressure)"
)
}
SinkError::Closed => {
tracing::debug!(rid = %rid, "detok: sink closed; aborting (client gone)")
}
}
let _ = st.fsm.apply(Event::Disconnect);
// Abort ONLY when the sink is full. `Closed` means the handler future is
// already gone, so its `AbortGuard` has run: it aborted and released the
// rid. A second abort from here is unordered with respect to that
// release, so it lands after a resubmit of the same rid has registered
// and deregisters the NEW request — the cross-wiring the rid registry
// exists to prevent, reached through the one abort producer that
// bypasses the guard's ordering.
if matches!(e, SinkError::Full) {
let _ = abort.send(AbortSource::Detok(rid.clone()));
}
table.remove(&rid);
}
}
}
/// Drop a matched stop TOKEN from the final chunk (Python `trim_matched_stop`,
/// token branch); `no_stop_trim` / non-token match keeps it.
fn trim_stop_token(token_ids: &mut TokenIds, matched: &Option<Matched>, no_stop_trim: bool) {
// Token id 0 is NOT a match: Python guards with `if not matched`, and 0 is
// falsy there, so it trims nothing. Trimming on 0 drops a real generated token
// for any model whose stop id happens to be 0.
if !no_stop_trim && matches!(matched, Some(Matched::Token(t)) if *t != 0) {
token_ids.pop();
}
}
/// Remove the matched stop string from the decoded final chunk (Python
/// `trim_matched_stop`, string branch); `no_stop_trim` keeps it. Truncates at
/// the FIRST occurrence.
fn trim_stop_str(text: &mut String, stop: &str, no_stop_trim: bool) {
if stop.is_empty() {
return;
}
if let Some(pos) = text.find(stop) {
text.truncate(if no_stop_trim { pos + stop.len() } else { pos });
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
/// A non-terminal chunk that can't be delivered (sink full → client
/// backpressure) drops the request AND aborts scheduler work — it does not
/// silently keep state, which would later read as a clean completion at EOS.
#[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()))
.unwrap();
let mut table = HashMap::new();
table.insert(
Rid::from("1"),
DetokState {
sink: EgressSink::Local(tx),
decode_logprob_text: false,
no_stop_trim: false,
decoder: None,
fsm: RequestState::Queued,
},
);
let (tm_tx, tm_rx) = flume::unbounded::<AbortSource>();
let ev = ChunkEvent {
rid: Rid::from("1"),
token_ids: vec![5],
..Default::default() // finish_reason None → non-terminal
};
handle_chunk(&mut table, ev, &DetokenizerBackend::Skip, &tm_tx);
// Request removed (no lingering state to be mistaken for success)...
assert!(!table.contains_key(&Rid::from("1")));
// ...and the scheduler was told to abort it.
assert!(matches!(
tm_rx.try_recv(),
Ok(AbortSource::Detok(rid)) if rid == Rid::from("1")
));
}
/// `trim_stop_str` reproduces the base's stop-string semantics: `stop: "3"` on
/// output " 1, 2, 3" yields " 1, 2, " by default and " 1, 2, 3" with
/// `no_stop_trim`.
#[test]
fn trim_stop_str_matches_base() {
let mut t = " 1, 2, 3".to_string();
trim_stop_str(&mut t, "3", false);
assert_eq!(t, " 1, 2, ");
let mut t = " 1, 2, 3".to_string();
trim_stop_str(&mut t, "3", true);
assert_eq!(t, " 1, 2, 3");
// Empty / absent stop is a no-op.
let mut t = "abc".to_string();
trim_stop_str(&mut t, "", false);
assert_eq!(t, "abc");
// The stop can occur twice in the final chunk
let mut t = "a STOP b STOP".to_string();
trim_stop_str(&mut t, "STOP", false);
assert_eq!(t, "a ");
let mut t = "a STOP b STOP".to_string();
trim_stop_str(&mut t, "STOP", true);
assert_eq!(t, "a STOP");
}
/// Two requests on the SAME shard keep separate entries. This is what a
/// A shard-hash collision now degrades to: the hash partitions, the rid
/// identifies. Keying the table by the hash made colliding rids one entry, so
/// `Register` evicted the first client's sink and their tokens were written to
/// the second client's connection. A single shard forces co-location
/// 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 mut table = HashMap::new();
let state = |tx| DetokState {
sink: EgressSink::Local(tx),
decode_logprob_text: false,
no_stop_trim: false,
decoder: None,
fsm: RequestState::Queued,
};
table.insert(Rid::from("alice"), state(tx_a));
table.insert(Rid::from("bob"), state(tx_b));
let (tm_tx, _tm_rx) = flume::unbounded::<AbortSource>();
let chunk = |rid: &str, id: i32| ChunkEvent {
rid: Rid::from(rid.to_string()),
token_ids: vec![id],
..Default::default()
};
handle_chunk(
&mut table,
chunk("alice", 11),
&DetokenizerBackend::Skip,
&tm_tx,
);
handle_chunk(
&mut table,
chunk("bob", 22),
&DetokenizerBackend::Skip,
&tm_tx,
);
let ids = |rx: &mut mpsc::Receiver<EgressItem>| match rx.try_recv() {
Ok(EgressItem::Frame(ev)) => ev.token_ids,
other => panic!("expected a frame, got {other:?}"),
};
assert_eq!(
ids(&mut rx_a),
vec![11],
"alice must not receive bob's tokens"
);
assert_eq!(
ids(&mut rx_b),
vec![22],
"bob must not receive alice's tokens"
);
assert_eq!(table.len(), 2, "neither registration evicted the other");
}
/// Drive a final (`finish_reason`) chunk through `handle_chunk` in skip mode and
/// return the emitted `Done` event.
fn final_chunk(
no_stop_trim: bool,
finish_reason: serde_json::Value,
ids: Vec<i32>,
) -> ChunkEvent {
let (tx, mut rx) = mpsc::channel::<EgressItem>(4);
let mut table = HashMap::new();
table.insert(
Rid::from("1"),
DetokState {
sink: EgressSink::Local(tx),
decode_logprob_text: false,
no_stop_trim,
decoder: None, // skip mode → output_ids passthrough
fsm: RequestState::Queued,
},
);
let (tm_tx, _tm_rx) = flume::unbounded::<AbortSource>();
let ev = ChunkEvent {
rid: Rid::from("1"),
token_ids: ids,
// Parsed from the wire map, so the trim paths are driven by the same
// shape Python emits rather than a hand-built enum.
finish_reason: Some(
serde_json::from_value(finish_reason).expect("finish reason must parse"),
),
..Default::default()
};
handle_chunk(&mut table, ev, &DetokenizerBackend::Skip, &tm_tx);
match rx.try_recv() {
Ok(EgressItem::Done(out)) => out,
other => panic!("expected Done, got {other:?}"),
}
}
/// Token id 0 is not a match: Python's `trim_matched_stop` guards with
/// `if not matched`, and 0 is falsy there. Trimming on it drops a real
/// generated token for any model whose stop id is 0.
#[test]
fn matched_token_zero_does_not_trim() {
let mut ids = vec![1, 2, 0];
trim_stop_token(&mut ids, &Some(Matched::Token(0)), false);
assert_eq!(ids, vec![1, 2, 0], "id 0 is not a matched stop");
// A real stop id still trims.
let mut ids = vec![1, 2, 3];
trim_stop_token(&mut ids, &Some(Matched::Token(3)), false);
assert_eq!(ids, vec![1, 2]);
}
/// A matched stop TOKEN is dropped from the surfaced `output_ids` by default
/// (but still counted in `completion_tokens`); `no_stop_trim` keeps it.
#[test]
fn stop_token_trimmed_from_output_ids() {
let fr = serde_json::json!({ "type": "stop", "matched": 3 });
let out = final_chunk(false, fr.clone(), vec![1, 2, 3]);
assert_eq!(out.token_ids, vec![1, 2], "matched stop token dropped");
assert_eq!(
out.completion_tokens, 3,
"generated count still includes it"
);
let out = final_chunk(true, fr, vec![1, 2, 3]);
assert_eq!(out.token_ids, vec![1, 2, 3], "no_stop_trim keeps it");
}
/// A non-stop finish (`length`, no `matched`) never trims.
#[test]
fn length_finish_keeps_all_tokens() {
let fr = serde_json::json!({ "type": "length", "length": 3 });
let out = final_chunk(false, fr, vec![1, 2, 3]);
assert_eq!(out.token_ids, vec![1, 2, 3]);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Tokenizer pool — CPU-bound, runs on pinned OS threads (off the async
//! executor). Each worker pulls a `Request` from the shared `flume` receiver,
//! fills `input_ids`, and moves the request back to the TokenizerManager inbox.
//!
//! The text→ids step is behind [`TextTokenizer`], implemented by
//! [`DynamoTokenizer`] (dynamo-tokenizers: HuggingFace / tiktoken / fastokens).
//! A non-skip server requires a real tokenizer (enforced at startup); under
//! `skip_tokenizer_init` the pool isn't spawned at all.
//!
//! Mirrors the Python `_tokenize_one_request` text path: when the request
//! already carries `input_ids` it skips tokenization (handled upstream in the
//! TokenizerManager `classify`); otherwise the prompt text is encoded here.
use std::path::Path;
use std::sync::Arc;
use crate::error::Error;
use crate::fsm::Event;
use crate::message::{Request, RequestKind, TokenIds};
use crate::runtime::Runnable;
use crate::tokenizer_manager::TmEvent;
/// Pluggable text→token-ids backend. `Send + Sync` so one instance is shared
/// (read-only) across all pinned workers.
pub trait TextTokenizer: Send + Sync {
fn encode(&self, text: &str) -> Result<TokenIds, Error>;
}
/// Load the tokenizer shared (Arc-backed) by the encode pool and detok shards.
/// `None` under `skip_tokenizer_init`, else required (missing/failed load → `Err`).
/// `tokenizer_path` is a tokenizer file, a model dir, or an HF Hub repo id
/// (resolved from the local cache — no network).
pub fn load_tokenizer(
tokenizer_path: Option<&str>,
revision: Option<&str>,
skip_tokenizer_init: bool,
) -> Result<Option<dynamo_tokenizers::Tokenizer>, String> {
if skip_tokenizer_init {
tracing::info!("skip_tokenizer_init: token ids in and out; no tokenizer/detokenizer");
return Ok(None);
}
let path = tokenizer_path.ok_or_else(|| {
"no tokenizer configured: set tokenizer_path or enable skip_tokenizer_init".to_string()
})?;
let file = resolve_model_file(path, revision, "tokenizer.json")
.ok_or_else(|| format!("tokenizer.json not found for '{path}'"))?;
let tokenizer = dynamo_tokenizers::Tokenizer::from_file_with_options(
&file,
dynamo_tokenizers::TokenizerOptions {
add_special_tokens: true,
},
)
.map_err(|e| format!("tokenizer load failed ({file}): {e}"))?;
tracing::info!(%path, "loaded tokenizer");
Ok(Some(tokenizer))
}
/// Resolve a model file from the tokenizer source: a dir → `dir/<file>`, a file →
/// its sibling, else an HF Hub repo id → the local cache. `None` if not found.
pub fn resolve_model_file(path: &str, revision: Option<&str>, filename: &str) -> Option<String> {
let p = Path::new(path);
if p.is_dir() {
let f = p.join(filename);
return f.is_file().then(|| f.to_string_lossy().into_owned());
}
if p.is_file() {
// `path` is a file (e.g. `tokenizer.json`); look for the sibling.
let f = p.parent()?.join(filename);
return f.is_file().then(|| f.to_string_lossy().into_owned());
}
// Not a local path → HF Hub repo id (offline cache lookup).
resolve_from_hub_cache(path, revision, filename)
}
/// Locate a file for an HF Hub repo id in the local cache (`HF_HOME`). Offline —
/// the scheduler pre-downloads the model. `None` if not cached.
fn resolve_from_hub_cache(repo_id: &str, revision: Option<&str>, filename: &str) -> Option<String> {
use hf_hub::{Cache, Repo, RepoType};
let rev = revision.unwrap_or("main");
Cache::from_env()
.repo(Repo::with_revision(
repo_id.to_string(),
RepoType::Model,
rev.to_string(),
))
.get(filename)
.map(|p| p.to_string_lossy().into_owned())
}
/// Real tokenizer over an already-loaded dynamo `Tokenizer` (Arc inside).
pub struct DynamoTokenizer {
inner: dynamo_tokenizers::Tokenizer,
}
impl DynamoTokenizer {
pub fn new(inner: dynamo_tokenizers::Tokenizer) -> Self {
Self { inner }
}
}
impl TextTokenizer for DynamoTokenizer {
fn encode(&self, text: &str) -> Result<TokenIds, Error> {
if text.is_empty() {
// Match Python sglang: reject an empty prompt as a 400 (`Validation`),
// not the misleading 500 a tokenize error would give.
return Err(Error::Validation("prompt cannot be empty".into()));
}
let encoding = self
.inner
.encode(text)
.map_err(|e| Error::Tokenize(e.to_string()))?;
// Vocab ids are non-negative and fit in i32.
Ok(encoding.token_ids().iter().map(|&id| id as i32).collect())
}
}
/// One tokenizer worker: pulls a `Request` off the shared inbox, fills
/// `input_ids`, returns it to the TokenizerManager. Pinned; backend shared.
pub struct TokenizerWorker {
rx: flume::Receiver<Request>,
tm: flume::Sender<TmEvent>,
tokenizer: Arc<dyn TextTokenizer>,
}
impl TokenizerWorker {
pub fn new(
rx: flume::Receiver<Request>,
tm: flume::Sender<TmEvent>,
tokenizer: Arc<dyn TextTokenizer>,
) -> Self {
Self { rx, tm, tokenizer }
}
}
impl Runnable for TokenizerWorker {
fn run(self) {
while let Ok(mut req) = self.rx.recv() {
// The tokenizer pool only ever receives generate requests. Encode,
// then advance the FSM: `TokenizeDone` on success (→ PreSendValidating).
let event = {
let RequestKind::Generate(g) = &mut req.kind else {
tracing::error!("tokenizer pool received a non-generate request");
continue;
};
match self.tokenizer.encode(g.text.as_deref().unwrap_or("")) {
Ok(ids) => {
g.input_ids = Some(ids);
Event::TokenizeDone
}
Err(err) => Event::Error(err),
}
};
let _ = req.state.apply(event);
if self.tm.send(TmEvent::Tokenized(req)).is_err() {
tracing::error!("tm inbox closed; dropping request");
break;
}
}
}
}
@@ -0,0 +1,217 @@
//! 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).
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::ring::EgressConsumer;
use crate::runtime::Runnable;
use crate::tokenizer_manager::{Senders, recv};
/// A monotonic counter bumped once per egress-ring 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
/// senders, so the runtime spawns it as a [`Runnable`].
pub struct Egress {
egress: EgressConsumer,
senders: Senders,
activity: ActivityCounter,
shutdown: flume::Receiver<()>,
}
impl Egress {
pub fn new(
egress: EgressConsumer,
senders: Senders,
activity: ActivityCounter,
shutdown: flume::Receiver<()>,
) -> Self {
Self {
egress,
senders,
activity,
shutdown,
}
}
}
impl Runnable for Egress {
fn run(self) {
// Reused across frames (`clear` keeps capacity) — steady state allocates nothing.
let shards = self.senders.detok.len();
let mut buckets: Vec<Vec<ChunkEvent>> = (0..shards).map(|_| Vec::new()).collect();
while let Some(bytes) = recv(self.egress.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 => {
for b in buckets.iter_mut() {
b.clear();
}
let decoded = for_each_chunk(body, |ev| {
// The rid picks the shard; a hash collision only co-locates two
// requests now, it no longer merges them.
buckets[ev.rid.shard(shards)].push(ev);
});
// Routing only fills the buckets; nothing is delivered until the
// sends below. So dropping them here makes rejection atomic for
// free: a frame whose columns drifted would otherwise deliver the
// requests decoded before the bad one, carrying another request's
// logprobs — the corruption the decoder's bounds checks exist to
// prevent. Better a lost frame than a silently wrong one.
if !decoded.ok {
// Dropping the frame keeps wrong data off the wire, but a
// request whose chunk was in it would otherwise wait forever:
// mid-stream it gets a hole, and if its FINAL chunk was here
// it never sees `Done` and the connection hangs — there is no
// server-side timeout. Fail from the HEADER's rids, not the
// buckets: a frame that fails at request 0 buckets nothing,
// so bucket-driven cleanup would leave every request in it
// hanging.
// Distinguish "failed N requests" from "named nobody": a
// frame whose header would not decode at all yields no rids,
// so nothing downstream fails and every request in it waits
// forever. That is the case worth paging on, and it used to
// 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 \
it will hang (header undecodable, or empty rid column)"
);
} else {
tracing::warn!(
rids = decoded.rids.len(),
"egress: bad batch frame; failing its requests"
);
}
for b in buckets.iter_mut() {
b.clear();
}
for rid in decoded.rids {
// 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 {
rid,
message: "internal error: malformed scheduler output frame".into(),
});
}
continue;
}
for (i, b) in buckets.iter_mut().enumerate() {
if b.is_empty() {
continue;
}
let chunks = DetokMsg::Chunks(std::mem::take(b));
if self.senders.detok[i].send(chunks).is_err() {
tracing::error!("egress: detok shard closed");
}
}
// Any frame off the ring = the scheduler produced output → alive.
self.activity.fetch_add(1, Ordering::Relaxed);
}
EGRESS_TAG_RESULT => {
if let Some((rid, msg)) = decode_result(body) {
self.route(&rid, msg);
}
}
EGRESS_TAG_ERROR => {
if let Some((rid, msg)) = decode_error(body) {
self.route(&rid, msg);
}
}
other => tracing::warn!(tag = other, "egress: unknown frame tag"),
}
}
}
}
impl Egress {
/// 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`).
#[inline]
fn route(&self, rid: &Rid, msg: DetokMsg) {
if self.senders.detok_for(rid).send(msg).is_err() {
tracing::error!("egress: detok shard closed");
}
}
}
/// Control result: `[rid, payload]` → single non-streamed delivery to the sink.
fn decode_result(body: &[u8]) -> Option<(Rid, DetokMsg)> {
let val = rmpv::decode::read_value(&mut &body[..]).ok()?;
let rmpv::Value::Array(arr) = val else {
return None;
};
let mut items = arr.into_iter();
let rid = Rid::from(items.next()?.as_str()?);
// The decode already owns the payload buffer — move it out.
let payload = match items.next()? {
rmpv::Value::Binary(b) => Bytes::from(b),
rmpv::Value::String(s) => Bytes::from(s.into_bytes()),
_ => return None,
};
Some((rid.clone(), DetokMsg::Result { rid, payload }))
}
/// Per-request failure: `[rid, message]` → terminal `Error` to the sink (→ 400).
fn decode_error(body: &[u8]) -> Option<(Rid, DetokMsg)> {
let val = rmpv::decode::read_value(&mut &body[..]).ok()?;
let rmpv::Value::Array(arr) = val else {
return None;
};
let mut items = arr.into_iter();
let rid = Rid::from(items.next()?.as_str()?);
let message = match items.next()? {
rmpv::Value::String(s) => s.into_str()?,
_ => return None,
};
Some((rid.clone(), DetokMsg::Fail { rid, message }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::DetokMsg;
use crate::message::frame_egress_error;
/// A framed error round-trips: `frame_egress_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 (rid, msg) = decode_error(&framed[1..]).expect("decodes");
let want = Rid::from("42");
assert_eq!(rid, want);
match msg {
DetokMsg::Fail { rid, message } => {
assert_eq!(rid.clone(), want);
assert_eq!(message, "invalid request: bad field");
}
_ => panic!("expected Fail"),
}
}
}