执行摘要
该 PR 为 sglang Rust 服务器新增了 tokenizer(文本编码)、detokenizer(增量解码)和 egress(调度器输出路由)三个核心模块,基于 dynamo-tokenizers 实现,是 Python TokenizerManager 的 Rust 替代,旨在降低延迟和消除 GIL 瓶颈。当前模块尚未接入主服务器,但为后续 Rust 推理管线奠定基础。
功能与动机
将 CPU 密集的 tokenize/detokenize 操作从 Python 主循环卸载到 Rust 线程池,利用 dynamo-tokenizers 实现无锁共享的分词器,避免 GIL 限制并提高服务器吞吐。同时通过基于 rid 的确定性 shard 路由消除 detok 状态访问的锁竞争。
实现拆解
- Tokenizer 模块 (
tokenizer.rs): 定义 TextTokenizer trait 和 DynamoTokenizer 实现;load_tokenizer 函数支持多个来源加载;TokenizerWorker 通过通道接收请求,编码后返回。该模块完全离线于主异步循环。
- Detokenizer 模块 (
detokenizer.rs): StreamDecoder trait 定义增量解码接口;DetokenizerBackend 以 Dynamo/Skip 两种模式运行;每个 shard 维护无锁 HashMap 管理请求状态,通过 FSM 交互处理 Chunk/Result/Error 事件。
- Egress 模块 (
egress.rs): 消费调度器输出环形缓冲区,按标签分帧,对 BATCH 帧按 rid shard 分桶后批量投递;解码失败时丢弃整帧以避免数据错乱,并提供 ActivityCounter 用于健康检查。
- 依赖锁定 (
Cargo.lock): 通过独立 commit 更新锁定文件,保证构建可重现。
rust/sglang-server/src/detokenizer.rs
核心 detokenizer 实现,定义流式解码接口和无锁 shard 管理
//! Detokenizer shards — CPU-bound, one pinned thread per shard.
//! Each shard owns a local `rid -> DetokState` map without lock:
//! a given rid is routed to exactly one shard for both Register and Chunks.
use std::collections::HashMap;
use crate::error::Error;
use crate::ids::Rid;
use crate::message::{ChunkEvent, EgressSink, TokenIds};
use crate::runtime::Runnable;
/// Per-request incremental decoder.
/// `step` feeds new token ids for one chunk and returns the newly decoded
/// text delta (empty if partial/incomplete multi-byte sequence).
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 — emits raw output ids. Used for `skip_tokenizer_init`.
Skip,
}
impl DetokenizerBackend {
/// Mint a per-request decoder, or None in skip mode.
fn new_decoder(&self) -> Option<Box<dyn StreamDecoder>> {
match self {
// stream seeded with empty prompt; correct for common case.
DetokenizerBackend::Dynamo(t) => Some(Box::new(DynamoDecoder {
stream: t.decode_stream(&[], true),
})),
DetokenizerBackend::Skip => None,
}
}
/// Decode each logprob token id to its own text (one id at a time).
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(),
}
}
}
rust/sglang-server/src/tokenizer_manager/egress.rs
调度器输出路由,负责帧分桶和错误处理
//! 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.
use crate::ids::Rid;
use crate::message::{ChunkEvent, EGRESS_TAG_BATCH, EGRESS_TAG_ERROR, EGRESS_TAG_RESULT, for_each_chunk};
use crate::ring::EgressConsumer;
use crate::runtime::Runnable;
/// Egress dispatcher stage.
/// Owns the egress-ring consumer + the detok-shard senders.
pub struct Egress {
egress: EgressConsumer,
senders: Senders,
activity: ActivityCounter,
shutdown: flume::Receiver<()>,
}
impl Runnable for Egress {
fn run(self) {
let shards = self.senders.detok.len();
// Reused buckets across frames (steady state allocates nothing).
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 {
EGRESS_TAG_BATCH => {
for b in &mut buckets { 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);
});
// If decoding failed, drop the entire frame: better a lost frame
// than a silently wrong one carrying another request's logprobs.
if !decoded.ok {
// … error handling with rids extracted from header …
continue;
}
// Deliver each shard's chunks in one send.
for (shard, chunks) in buckets.iter().enumerate() {
if !chunks.is_empty() {
let _ = self.senders.detok[shard].send(DetokMsg::Chunks(chunks.clone()));
}
}
},
// … other tags …
}
self.activity.fetch_add(1, Ordering::Relaxed);
}
}
}
评论区精华
- mrain 在
detokenizer.rs 第 377 行质疑 matched 变量是否可能遇到 Matched::Tokens(Vec) 变体,若为真则当前处理可能遗漏一个分支。未见回复,PR 已合并。
- mrain 还指出
handle_result 和 handle_fail 中各有一处死代码(从 table 移除 st 后立即丢弃),建议清理。此类问题不影响正确性,但可能误导后续维护。
风险与影响
- 测试缺口: 无直接测试文件,需后续覆盖增量解码一致性和帧解析场景。
- 集成阶段: 新模块目前未被入口调用,不会影响现有服务器,但后续集成时需要仔细对比 Python 行为。
- 外部依赖: 引入
dynamo-tokenizers 需关注其 API 稳定性。
- 性能预期: 将 decode 从 Python 移到 Rust 线程池应显著降低首 token 延迟,但 shard 数配置需与 CPU 核数匹配。
关联脉络
该 PR 与 #32871(update Cargo.lock)位于同一 commit 链,后者为前者准备依赖。这是 sglang Rust 服务器基础设施建设的又一进展,此前已有 speculative decoding 等模块的 Rust 实现。
参与讨论