Rust in Everything

路由层

主线位置:一个请求的一生在「HTTP 进来」之前还有一跳:部署了多个引擎副本时,请求先到路由器,由它挑一个副本(PD 分离时挑一对)。这一跳不在 vLLM 和 SGLang 的主包里,是单独发布的 Rust 程序。

版本:SGLang v0.5.22(sgl-model-gateway/、experimental/sgl-router/)、vllm-project/router v0.1.16;对照 vLLM v0.32.0

先说结论:

  • 两个引擎的 Rust 路由器是同一份代码分出来的。 2024-10 SGLang 在 rust/ 目录写了一个 Rust 路由器,后来改名 sgl-router、再改名 sgl-model-gateway(SMG);vllm-project/router 是 SMG 的分支,v0.1.16 起它的 gRPC 后端直接用 vLLM 自己的 Rust crate 做模板和分词。
  • SMG 的 cache_aware 不看引擎的真实缓存。 它按请求文本的字符在路由器里维护一棵近似前缀树,记的是「这段文本上次发给了谁」;负载差得多时干脆不看树,按排队长度挑。
  • 实验中的 experimental/sgl-router 换了思路。 它订阅引擎发布的 KV 事件(ZMQ SUB + msgpack),用和引擎相同的哈希把 token 块串成链建索引,查到的是引擎确实缓存着的块。
  • PD 分离时 SMG 一次挑一对,往请求体里塞同一个 bootstrap_room,把预填充和解码请求同时发出;vllm-project/router 对 vLLM 默认是两段式:先预填充、拿到 KV 传输参数再发解码。

测过的只有 experimental/sgl-router 自带的微基准(树的插入、前缀匹配,选一次副本的耗时),见文末「实测」;各策略的命中率、路由器挡在副本前的附加延迟都没测过,见「待实测」。

flowchart LR
  r0["rust/<br/>2024-10 v0.3.5"] --> r1["sgl-router<br/>2024-12 v0.4.0.post2"]
  r1 --> smg["sgl-model-gateway(SMG)<br/>2025-12 v0.5.6.post1"]
  smg -->|"2025-12 分支"| vr["vllm-project/router<br/>v0.1.16"]
  smg -.->|"2026-01 文本栈抽成外部 crate"| ext["tool-parser 等<br/>crates.io"]
  vc["vLLM rust/ 的<br/>vllm-chat、vllm-tokenizer、vllm-text"] -->|"git 依赖 v0.29.0"| vr
  exp["experimental/sgl-router<br/>2026-05 v0.5.13"]
  smg -.->|"思路不同的新实现"| exp

谱系

时间线

全部来自本地克隆的 git 历史(按路径的 git log 和 git tag --contains)。「首个发布」指最早包含该提交的 tag。

日期提交事件首个发布
2024-10-28SGLang 3839be2913「[Router] Add a rust-based router」,代码在 rust/v0.3.5
2024-12-12SGLang e3b3acfa6frust/ 改名 sgl-router/v0.4.0.post2
2025-12-05SGLang 49dfa1d891sgl-router/ 改名 sgl-model-gateway/v0.5.6.post1
2025-12-05router d8bd221vllm-project/router 首个提交「Initial commit: VLLM Router」本地最早的 tag 是 v0.1.9(2026-01-21)
2026-01-25SGLang 2c1e2674cc 等SMG 删掉自带的工具解析器、推理解析器、分词器、协议类型,改用已发布的 cratev0.5.9
2026-02-23SGLang b2166a2f33「pin internal crate dependencies to exact versions」v0.5.10rc0
2026-05-25SGLang 6e8fe176be新建 experimental/sgl-router/,「experimental Rust HTTP router for SGLang worker pools」v0.5.13
2026-09-24router 3bf32ef「Rust gRPC Engine-Client Backend」,引入 vllm-chat 等v0.1.16
2026-10-08router 682446b「multi-tier KV cache placement index from engine events」v0.1.16

两点要注意:

  • 今天 SGLang 的 rust/ 目录和 2024 年的 rust/ 不是一回事。 2024 年那个就是路由器,改名后目录空出来;现在的 rust/ 放的是 2026 年起的进程内扩展(sglang-server、sglang-radix-tree 等,见两种前端)。按路径查历史时会把两段混在一起。
  • SMG 在 SGLang 仓库里的活跃度在 2026-01 之后掉下来了。 sgl-model-gateway/ 路径下按月的提交数:2025-12 是 174,2026-01 是 134,之后每月 2–15。同期它把文本栈换成了 crates.io 上的外部 crate,并钉死版本。

外部 crate 钉死在 1.0.x

SMG 的依赖里,原来自带的几块现在都是精确版本(Cargo.toml#L82):

# sgl-model-gateway/Cargo.toml
reasoning-parser = "=1.0.0"
openai-protocol = { version = "=1.0.0", features = ["axum"] }
tool-parser = "=1.0.0"
llm-tokenizer = "=1.3.2"
smg-auth = "=1.0.0"
wfaas = "=1.0.2"
data-connector = "=1.0.0"
smg-mcp = "=1.0.0"
smg-wasm = "=1.0.0"
smg-mesh = "=1.0.0"
# ...
smg-grpc-client = "=1.0.0"

同一批 crate,vLLM v0.32.0 的工作区引用的是 openai-protocol = "1.6.0"、tool-parser = "1.2.0"(rust/Cargo.toml#L71、#L133),vLLM 还从 git 组织 smg-project 取 llm-multimodal(#L64)。也就是说这些 crate 在 SGLang 仓库之外还在发新版本,SGLang 里的 SMG 停在 1.0.x。主开发现在在哪个仓库,离线无法确认。文本栈的依赖关系见共享的文本栈。

发布形态

  • SMG:PyPI 包名 sglang-router,maturin 构建,模块 sglang_router.sglang_router_rs(pyproject.toml#L53);镜像 lmsysorg/sgl-model-gateway(release-docker-gateway.yml#L37)。它不是 sglang wheel 的依赖;PD 部署的 cookbook 用 python -m sglang_router.launch_router --pd-disaggregation 启动它(DeepSeek-V3_2.mdx#L421)。同一个 Python 包里还有一个纯 Python 的 MiniLoadBalancer,注释写着「for testing」(mini_lb.py#L1)。
  • experimental/sgl-router:publish = false,只出二进制(Cargo.toml#L11),夜间镜像 lmsysorg/sglang-staging:experimental-router-dev(nightly-experimental-sgl-router-docker.yml#L13)。
  • vllm-project/router:二进制 vllm-router 加一个 PyO3 cdylib,PyPI 包 vllm-router。README 自称「a fork of SGLang Model Gateway」,目前「only minimal changes」(README.md#L339)。vLLM 不依赖它;vLLM 文档在 MoRIIO 连接器的 PD 部署处推荐它作代理(moriio_connector_usage.md#L81)。

规模(tokei,Rust code 行,含测试和 benches):

项目Rust code其中 src/
SMG sgl-model-gateway/74,37249,083
experimental/sgl-router/72,197(含 sgl-kv-indexer 5,218)—
vllm-project/router60,24046,114

SMG 的 cache_aware:按字符的近似前缀树

一句话

路由器不问引擎缓存了什么,只记自己把什么文本发给了谁。下一个请求来时,在这棵树上找最长前缀,前缀够长就发给上次那个副本,赌它的 KV cache 还在。文件头的注释把整个策略讲完了(cache_aware.rs#L1):

// sgl-model-gateway/src/policies/cache_aware.rs
    A system is considered imbalanced if both conditions are met:
    1. (max - min) > abs_threshold
    2. max > rel_threshold * min
// ...
    This strategy maintains an approximate radix tree for each worker based on request history,
    eliminating the need for direct cache state queries. The tree stores raw text characters
    instead of token IDs to avoid tokenization overhead.
flowchart TB
  req["请求到达<br/>取路由文本"] --> load{"负载不均?<br/>max-min 大于 abs 且 max 大于 rel 乘 min"}
  load -->|"是"| sq["最短队列<br/>并列随机"]
  load -->|"否"| match["在树上找最长前缀<br/>match_rate = 匹配字符数 / 总字符数"]
  match --> th{"match_rate 大于 cache_threshold?"}
  th -->|"是"| hit["发给前缀所在节点的副本"]
  th -->|"否"| ml["最短队列<br/>并列随机"]
  sq --> ins["把文本插入树<br/>记到选中的副本名下"]
  hit --> ins
  ml --> ins

走一遍源码

select_worker(cache_aware.rs#L412)先判断负载:

// sgl-model-gateway/src/policies/cache_aware.rs
let is_imbalanced = max_load.saturating_sub(min_load) > self.config.balance_abs_threshold
    && (max_load as f32) > (min_load as f32 * self.config.balance_rel_threshold);

if is_imbalanced {
    return self.select_worker_min_load(/* ... */);
}

load() 是路由器自己记的在途请求数,不是引擎上报的队列。不均衡时走 select_worker_min_load:在健康副本里挑负载最小的,仍然把文本插进树,记到选中的副本名下(#L345)。

均衡时查树(#L436):

// sgl-model-gateway/src/policies/cache_aware.rs
let result = tree.prefix_match_with_counts(text);
let match_rate = if result.input_char_count == 0 {
    0.0
} else {
    result.matched_char_count as f32 / result.input_char_count as f32
};

let selected_idx = if match_rate > self.config.cache_threshold {
    // ... 按 URL 找到 result.tenant 对应的副本,且要健康
} else {
    // Low cache match: use worker with minimum load. Tie break randomly.
    // ...
};

命中率不够时挑负载最小的副本(#L452)。找到的副本已经下线时,把它从树里删掉,退回第一个健康副本(#L511)。

树按 池::模型 分开,预填充池、解码池、普通池各一棵(#L84)。注释说明了原因:同一个提示先发预填充再发解码,如果共用一棵树,后一次插入会把前一次的归属覆盖掉,策略就在两个池之间来回翻。

📝NOTE

文件头注释第 c 条说命中率低时「Route to the worker with smallest tree size」(#L29),代码实际挑的是负载最小的副本。max_tree_size 的注释说是「Maximum nodes per tree」(#L58),驱逐代码比较的却是每个副本名下的字符数(见下节)。以代码为准。

树长什么样

tree.rs#L230:

// sgl-model-gateway/src/policies/tree.rs
struct Node {
    /// Children nodes indexed by first character.
    children: DashMap<char, NodeRef, CharHasherBuilder>,
    text: RwLock<NodeText>,
    /// Per-tenant last access epoch for LRU ordering.
    tenant_last_access_time: DashMap<TenantId, u64>,
    parent: RwLock<Option<NodeRef>>,
    last_tenant: parking_lot::RwLock<Option<TenantId>>,
}
  • 键是 char。 子节点按首字符索引,边上存一段文本(压缩前缀树)。比较时先按字节比,公共部分全是 ASCII 就直接用字节数,否则退回逐字符比较,保证不在 UTF-8 中间切开。
  • 一棵树装所有副本。 每个节点记一张「副本 → 最近访问时间」的表(文档里叫 tenant)。查前缀时走到最深的匹配节点,取那里的一个副本,优先用缓存的 last_tenant(#L531)。多个副本共享同一前缀时,选哪个和负载无关。
  • 并发靠细粒度锁。 子节点表是 DashMap,根节点 32 个分片、其他节点 8 个;节点文本是 RwLock。
  • 访问时间是一个全局计数器,不是时钟。 查前缀时只有八分之一的概率更新时间戳,减少写竞争(#L601);插入时总是更新。

驱逐由后台线程按 eviction_interval 周期跑 evict_tenant_by_size(#L718):遍历全树,把每个副本的「叶子」(该副本在这个节点有记录、子节点里没有)按时间戳放进最小堆,从最旧的开始删,直到这个副本名下的字符数不超过 max_tree_size(#L750)。节点上没有副本、也没有子节点时整个摘掉。

路由文本是什么

树里存的不是模板渲染后的提示,而是 extract_text_for_routing() 抽出来的文本。PD 路由器的注释说它会走遍所有消息(system、历史轮次、当前消息、工具内容),普通路由器用的是同一个函数(pd_router.rs#L956)。这个函数在外部 crate openai-protocol 里,具体怎么拼接,本地没有源码,无法确认。

所以「近似」有三层:

  1. 树记的是发过什么,不是引擎还留着什么。引擎那边驱逐了,路由器不知道。
  2. 单位是字符,不是 token。两个文本前缀相同,token 化后的前缀长度未必成比例。
  3. 匹配的是消息文本,不是加上聊天模板后的完整输入。

好处是不需要分词器、不需要和引擎通信,路由器可以挡在任何 OpenAI 兼容的后端前面。

阈值和默认值

同一组参数有三套默认值,取决于怎么启动:

参数Rust 结构体 DefaultRust 二进制命令行Python 启动器 launch_router
cache_threshold0.50.30.3
balance_abs_threshold326464
balance_rel_threshold1.11.51.5
驱逐间隔(秒)3012060
max_tree_size(字符/副本)10,00067,108,864(2^26)2^26
默认策略—cache_awarecache_aware

出处:policies/mod.rs#L106、main.rs#L150、router_args.py#L54。实际部署走命令行,结构体的 Default 只在库内直接构造时用到。驱逐间隔两种启动方式不一样:二进制 120 秒,Python 启动器 60 秒。

按命令行默认值算一个例子:两个副本在途请求数 100 和 40,差 60,不超过 64,判为均衡,照常查树;100 和 30,差 70 且 100 > 1.5 × 30,判为不均衡,直接发给 30 那个。

vllm-project/router v0.1.16 的 cache_aware 是同一份代码:结构体默认值和命令行默认值与上表前两列相同(policies/mod.rs#L154、main.rs#L163),默认策略也是 cache_aware(main.rs#L120)。

策略代码相同,喂给树的文本却不同。vllm-project/router 自己实现 extract_text_for_routing():completions 请求取 prompt(spec.rs#L888);chat 请求只取 session_params.session_id,没有就是空串,消息内容完全不参与(spec.rs#L563)。HTTP 路由器只从这一个函数拿路由文本(router.rs#L1084)。所以在 v0.1.16 上,不带会话 id 的 chat 请求走 cache_aware 时,所有请求的路由键都一样,前缀亲和不起作用;要靠前缀把 chat 请求分到同一副本,得在请求里带 session_params.session_id,或者用 completions 接口。下文的实测能看到这一点。

SMG 命令行可选的策略还有 random、round_robin、power_of_two、prefix_hash、manual;src/policies/ 下另有 bucket、consistent_hashing。

experimental/sgl-router:订阅 KV 事件,按 token 块建索引

换了什么

新路由器要回答的问题不变(哪个副本缓存了这个请求最长的前缀),但信息来源换成引擎自己:

SMG cache_awareexperimental/sgl-router
索引的单位字符token 块(块大小由引擎上报)
信息来源路由器自己的发送记录引擎发布的 BlockStored / BlockRemoved / AllBlocksCleared 事件
引擎驱逐后不知道收到 BlockRemoved 就删
路由器要做的事抽文本渲染聊天模板、分词、算块哈希
引擎侧前提无引擎打开 KV 事件发布

为了「哈希的是引擎缓存的同一串 token」,它用 Dynamo 的 crate 和 sglang-processor 渲染模板、分词(Cargo.toml#L25):

# experimental/sgl-router/Cargo.toml
# Pin Dynamo versions and commit Cargo.lock so builds are reproducible.
dynamo-tokenizers = "=3.1.0"
# Renders the model's chat template (HF Jinja, or Dynamo's built-in encoder for
# template-less models like DeepSeek-V4) so cache-aware routing hashes the same
# tokens the engine caches.
dynamo-renderer = "=7.0.1"
# ...
sglang-processor = { path = "../../rust/sglang-processor", default-features = false, features = ["render", "openai"] }

引擎一侧的发布默认关:SGLang 的 --kv-events-config 默认 None,给了才发布(observability.py#L144)。路由器一侧的默认策略是 round_robin(换成 --chat-routing reorg 时默认 power_of_two),要显式 --policy cache_aware 才用索引(cli.rs#L235)。

sequenceDiagram
  participant E as SGLang 引擎(每个 DP rank)
  participant S as 订阅任务(每个 worker × rank 一个)
  participant P as pump 任务
  participant T as HashTree(分片)
  participant R as 路由请求
  E->>S: PUB 三帧:topic、seq、msgpack 批
  S->>S: seq 前跳:向 replay ROUTER 补取
  S->>P: mpsc 按序转发
  P->>T: BlockStored 插入 / BlockRemoved 删除
  R->>R: 渲染模板、分词、算块哈希链
  R->>T: prefix_depths:各副本命中多少块
  T-->>R: 命中块数,选副本

线上格式与重放修复

订阅端的文件头写清了协议(subscriber.rs#L13):

// experimental/sgl-router/src/state/kv_events/subscriber.rs
//! # Wire format (3-frame multipart)
//!
//! 1. `topic_bytes` — empty by default, present even when empty.
//! 2. `seq_bytes` — 8-byte big-endian signed `i64`, dense per publisher. The
//!    `-1` sentinel (`ZmqEventPublisher.END_SEQ`) ends a replay; seen on the
//!    PUB stream it becomes a [`WorkerEvent::PublisherReset`].
//! 3. `payload` — msgpack-encoded [`KvEventBatch`].
//!
//! # Sequence repair
//!
//! A seq that regresses on one socket is a publisher restart and is
//! forwarded as a reset, except batch 0, which the pump already resolves from
//! the stream's origin. A forward gap is re-fetched from the publisher's
//! replay ROUTER when `/server_info` advertises one; live frames wait in the
//! SUB socket meanwhile, so the pump always sees this rank in order.
  • 每个 (worker, dp_rank) 一个 SUB socket、一个 tokio 任务,用纯 Rust 的 zeromq crate;地址是 tcp://{host}:{port_base + dp_rank},host 从 worker 的 /server_info 取。
  • 序号倒退当作发布端重启,向下游发一个 reset;序号前跳说明丢了批,用 DEALER 向发布端的 replay ROUTER 补取,等待上限 2 秒(REPLAY_TIMEOUT,注释写着「arbitrary」)。补取期间新到的帧留在 SUB socket 里,所以同一个 rank 的事件总是按序。
  • 发布端是 SGLang 的 ZmqEventPublisher,PUB 加可选的 replay ROUTER,内存里留最近若干批供重放,END_SEQ 是 -1(kv_events.py#L388)。replay 端点要在 --kv-events-config 里显式配。
  • 背压:订阅任务 await tx.send(),通道满了就不再读 ZMQ,丢弃发生在发布端的高水位线,不在路由器里堆积。

msgpack 的形状在 wire.rs#L1:外层 EventBatch 是数组 [ts, events, attn_dp_rank],每个事件是带 type 键的 map;注释说这和 vLLM 的 KV 事件编码相同。

块哈希要和引擎逐位一致

hash.rs#L57 复刻 SGLang 的页哈希:每页 SHA256,前一页的完整 32 字节摘要作为下一页的输入,token 按 4 字节小端写入,最后取摘要前 64 位当有符号 i64。EAGLE 一类模型用相邻 token 二元组算哈希,另有一个 compute_block_hashes_bigram。

只要一侧的分词、模板或哈希和引擎差一个 token,后面整条链都对不上,命中就是 0。这也是它要把 Dynamo 和 sglang-processor 拉进来的原因。

索引结构

tree.rs#L1 是一棵以块哈希为键的前缀树:每个节点是一个块,子节点按下一个块的哈希索引,节点上记哪些 worker 在哪一层存储(设备、主机内存等)持有这条链。

  • 按根块哈希分片,每片一把 RwLock。一条链只落在一个分片里,路由查询只拿一把读锁,事件写入不再挡住所有查询。
  • 反向索引 block_hash → 节点集合:BlockRemoved 只带哈希不带父块,没有它就得扫全树。
  • 在途前缀:KV 事件比路由决定晚到。--cache-pending-prefix-ttl-ms 让路由器在事件确认之前,先把刚发出去的前缀记在目标 worker 名下一段时间,让同一冷前缀的一批请求落到一起;默认 0,即关闭(cli.rs#L309)。

前缀来源有两种:本地这棵树(radix_tree,默认),或外部的 sgl-kv-indexer gRPC 服务(给了 --kv-indexer-endpoint 时)(cli.rs#L301)。

命中后不是直接选最深的那个。候选要命中至少 1024 个 token(cache_affinity_min_matched_tokens,types.rs#L655),候选数在 8 到 32 之间按健康 worker 的 5% 取,再经过压力保护和负载比较。SMG 用的是「命中字符占比超过 0.3」,这里换成了绝对 token 数。

vllm-project/router 也在做同样的事

v0.1.16 的 src/kv_index/ 是一个 ZMQ SUB 加 DEALER 重放的 KV 事件索引,src/kv_hash.rs 复刻 vLLM 的 sha256_cbor 块哈希。两者都只在 lib.rs 里声明(lib.rs#L9),没有任何策略或配置引用,还没接上。

PD 路由:一次挑一对

SMG:双发,注入 bootstrap

预填充 / 解码分离时,SMG 的 select_pd_pair 分别用预填充策略和解码策略各挑一个 worker(pd_router.rs#L976),然后往请求体里写三个字段(#L242):

// sgl-model-gateway/src/routers/http/pd_router.rs
obj.insert(
    Self::BOOTSTRAP_HOST_KEY.to_string(),
    Value::from(prefill_worker.bootstrap_host()),
);
obj.insert(
    Self::BOOTSTRAP_PORT_KEY.to_string(),
    // ... prefill_worker.bootstrap_port()
);
obj.insert(
    Self::BOOTSTRAP_ROOM_KEY.to_string(),
    Value::from(super::pd_types::generate_room_id()),
);

bootstrap_room 是一个 [0, 2^63-1] 的随机数,和 Python 一侧 random.randint(0, 2**63 - 1) 对齐(pd_types.rs#L60)。批量请求每条一个。同一个 JSON 同时发给两边,两边的引擎凭同一个 room 号在预填充节点的 bootstrap 服务上配对,KV 从预填充节点传到解码节点。

两个请求在同一个 handler 任务里并发发出,没有用 tokio::spawn,这样客户端断开时解码请求一起取消(#L701)。先等预填充的结果:预填充失败就丢掉解码的 future,否则解码那边会一直等 KV,直到 300 秒超时(#L732)。

sequenceDiagram
  participant C as 客户端
  participant G as SMG
  participant P as 预填充 worker
  participant D as 解码 worker
  C->>G: POST /v1/chat/completions
  G->>G: 各挑一个 P、D,生成 bootstrap_room
  par 同时发出
    G->>P: 原请求 + bootstrap_host/port/room
  and
    G->>D: 原请求 + bootstrap_host/port/room
  end
  P-->>D: 按 room 配对后传 KV
  P-->>G: 预填充完成(失败则取消 D)
  D-->>G: 流式输出
  G-->>C: 流式输出

bootstrap 注册表本身在引擎里:Python 默认路径由 TokenizerManager 承担,打开 SGLANG_RUST_SERVER 后由 Rust 的 sglang-server 承担(见两种前端)。

vllm-project/router 对 vLLM:默认两段式

vLLM 的 KV 连接器协议不同,没有 room 号,靠 kv_transfer_params。VllmPDRouter 的默认流程(vllm_pd_router.rs#L1279):

  1. 预填充请求改成 max_tokens = 1、stream = false(#L578),附上 do_remote_decode: true 的传输参数,发给预填充节点并等它返回。
  2. 从预填充响应里取 kv_transfer_params,附到原请求上发给解码节点。

并发的只有两种情况:NIXL 连接器且能拿到预填充节点的推送身份(try_build_concurrent_requests,#L1680);服务发现模式下再加上 MoRIIO 的 WRITE 传输模式(#L936)。其余情况(包括 Mooncake)走两段。两段式多等了一次预填充响应,代价待实测。

gRPC 后端:路由器自己做模板和分词

SMG 和 vllm-project/router 都有 gRPC 路由:路由器在 Rust 里渲染模板、分词,向 worker 发 token id,再把输出反分词。

  • SMG 的 gRPC 路由用外部的 llm-tokenizer(在 lib.rs 里重新导出为 tokenizer)、tool-parser、reasoning-parser,对接实现了 SMG gRPC 协议的 worker。SGLang 的 --smg-grpc-mode 和 vLLM 的 --grpc(grpc_server.py#L31,依赖 smg-grpc-servicer)都是这种 worker,所以 SMG 也能驱动 vLLM。
  • vllm-project/router 的 grpc:// worker 只走 vLLM 自己的 crate(backend/mod.rs#L1):
// src/backend/mod.rs(vllm-project/router)
//! - All-HTTP: policy selects, then `router.rs` reverse-proxies OpenAI JSON
//!   (no chat/tokenizer frontend on the critical path).
//! - All-gRPC: `EngineFrontend::prepare(chat)` → `token_ids`, then policy
//!   selects, then `EngineFrontend::dispatch` (`convert` + GenerateStream +
//!   detok).
//!
//! gRPC wire types come from crates.io `vllm-proto`. Chat+tokenize is
//! `vllm-chat` + `vllm-tokenizer` only (Cargo git, not `pip install`).

对面是 vllm-rs 的 vllm.Inference/GenerateStream,也就是打开 Rust 前端并给了 --grpc-port 的 vLLM。依赖钉在 vLLM 的 v0.29.0 tag(Cargo.toml#L102),比本卷的 v0.32.0 早三个版本。

实测:experimental/sgl-router 的微基准

在 M2 Max 笔记本上对 SGLang v0.5.22 的 experimental/sgl-router 跑 cargo bench --locked,下面是 criterion 报的中位数。这是单独调用数据结构和策略函数的耗时,没有网络、没有 worker,不等于路由器挡在副本前多出来的延迟。

benches/tree_lookup.rs 测的是按块哈希建的那棵树(HashTree)。参数 wA_bpwB_qC 表示 A 个 worker、每个 worker B 个块、查询 C 个块:

操作条件耗时
插入 32 个块不带父哈希(parent_none)1.27 µs
插入 32 个块带父哈希、要先跨分片找父节点(parent_some)1.35 µs
前缀匹配w4_bpw32_q80.20 µs
前缀匹配w16_bpw64_q320.67 µs
前缀匹配w64_bpw128_q641.29 µs
前缀匹配w128_bpw256_q1282.54 µs
前缀匹配,后台线程同时插入 / 删除从根写入1.43 µs
前缀匹配,后台线程同时插入 / 删除接续写入1.55 µs

benches/policy_select.rs 测选一次 worker:

策略worker 数耗时
round_robin4、16、64、2565.8–5.9 ns,与 worker 数无关
random16 / 64 / 256181 ns / 563 ns / 2.24 µs
power_of_two4、16、64、25665–72 ns

树的两类操作都在微秒量级,前缀匹配随 worker 数和查询长度增长。只跑了这一台机器、这一个仓库的基准。

实测:vllm-project/router

微基准

cargo bench --locked --bench routing_input,M2 Max,criterion 中位数。输入是仓库自带的确定性语料,不联网。

项目200 B2 KiB16 KiB
取路由文本:completions16.5 ns40 ns412 ns
取路由文本:chat1.5 ns1.5 ns1.5 ns
选副本:cache_aware(已预热的热集合)1.50 µs2.75 µs12.3 µs
选副本:rendezvous_hash3.51 µs12.7 µs83.6 µs

chat 那一行不随长度变,原因就是上面说的:它只读会话 id。

更长的输入:cache_aware 命中时 128 KiB 88 µs、1 MiB 693 µs,冷请求 18 µs、134 µs;1 MiB 的中文文本命中要 3.85 ms。rendezvous_hash 对请求体文本做哈希,1 MiB 要 4.99 ms,带会话头时 1.36 µs。副本数 1 / 4 / 16 / 64 时,cache_aware 选一次 2.66 / 2.75 / 3.08 / 4.25 µs,rendezvous_hash 11.5 / 12.4 / 16.0 / 30.3 µs。

挡在假 worker 前

用仓库自带的端到端测试(tests/router_overhead_bench.rs):起一个路由器进程,后面 4 个假 worker(不解析 JSON、不 sleep),客户端发完一个等返回再发下一个;同一组输入还会直连假 worker 跑一遍,两者相减就是路由器的附加延迟。M2 Max,每格预热 3 s、测 10 s,整组重复 3 次取中位数;驱逐间隔设成 3600 s,树只增不减。

场景输入并发 1 均值 / 比直连多并发 64 req/s(直连)负载集中度 并发 1 / 64最大 RSS
completions,cache_aware,热语料200 B0.86 / +0.43 ms1,872(3,685)4.00 / 1.0038 MiB
completions,cache_aware,热语料16 KiB1.27 / +0.71 ms1,107(2,315)4.00 / 1.1251 MiB
completions,cache_aware,冷语料16 KiB1.23 / +0.68 ms1,106(2,269)4.00 / 1.04310 MiB
completions,rendezvous_hash,热语料16 KiB1.70 / +1.06 ms1,115(2,315)1.13 / 1.1347 MiB
chat,cache_aware,热语料200 B0.93 / +0.45 ms1,791(3,539)4.00 / 1.0438 MiB
chat,cache_aware,热语料16 KiB1.24 / +0.69 ms1,069(2,312)4.00 / 1.0448 MiB

「负载集中度」是测试里的 hotspot:副本数乘以最忙副本的份额,1 表示完全均匀,4 表示全落在一个副本上。

  • 单个请求经过路由器多 0.4–0.7 ms(cache_aware),rendezvous_hash 在 16 KiB 时多 1.06 ms,和它在微基准里要哈希整个请求体一致。
  • 并发 1 时 cache_aware 把所有请求都发给同一个副本:没有在途请求,负载永远「均衡」,树的匹配结果决定去向。chat 的路由键全是空串,结果一样。并发 64 时在途请求拉开差距,按负载分散开,集中度回到 1 附近。
  • 并发 64 时经过路由器的吞吐约为直连的一半,但路由器每千个请求只用 110–370 ms CPU,折合不到半个核,瓶颈不在路由器的 CPU 上;具体在哪里这次没有查。
  • 冷语料每个请求往树里加一整段新文本,16 KiB、并发 64 时 RSS 涨到 310 MiB;生产配置有驱逐(命令行默认 120 s),这一项不代表实际占用。

局限:只有一台机器,客户端、假 worker 和路由器共用它;每格 10 s;三次重复之间的均值差多在 5–25%,差距在这个范围内的不要下结论。180 格里有一格(第 3 次重复的 rendezvous_hash / 200 B / 热语料 / 并发 64)路由器进程因端口被占没起来,请求全部失败,这一格只用了另外两次。

待实测

  • sgl-model-gateway/benches/tree_benchmark.rs:SMG 在锁定的 tag 上没有 Cargo.lock,没跑。
  • KV 事件驱动的那条路(experimental/sgl-router)挡在假 worker 前的试验:假 worker 要模拟 KV 事件的发布端,没做。
  • 真实引擎的前缀命中率和 TTFT:两个以上副本,round_robin 对 cache_aware,SMG 对 vllm-project/router;条件允许时加上 experimental/sgl-router 的 KV 事件模式。待实测(C3-6)。
  • PD 两段式与双发的差别:待实测(C3-7)。

小结

  • 谱系:SGLang 2024-10 的 rust/ 路由器 → sgl-router → sgl-model-gateway(SMG)→ 2025-12 分出 vllm-project/router。SMG 2026-01 起把文本栈换成外部 crate 并钉在 1.0.x,此后在 SGLang 仓库里改动很少。
  • SMG 的 cache_aware 记的是「什么文本发给了谁」:按字符的压缩前缀树,命中率过阈值就跟随,负载差过双阈值就按在途请求数挑。阈值有三套默认值,实际部署看命令行或 Python 启动器。
  • experimental/sgl-router 订阅引擎的 KV 事件,三帧 msgpack,序号倒退当重启、前跳就向 replay ROUTER 补取;按与引擎相同的 SHA256 链给 token 块建索引,要求路由器和引擎的模板、分词逐 token 一致。
  • vllm-project/router 也在写 KV 事件索引,v0.1.16 还没接进策略;它的 gRPC 后端直接复用 vLLM 的 vllm-chat / vllm-tokenizer / vllm-text。
  • PD:SMG 同时发两个请求,用随机 bootstrap_room 配对;vllm-project/router 对 vLLM 默认先预填充、再带 kv_transfer_params 发解码,只有 NIXL 推送和 MoRIIO WRITE 两种情况并发。
  • experimental/sgl-router 的微基准(M2 Max 笔记本):块哈希树插入 32 块 1.27–1.35 µs,前缀匹配 0.20–2.54 µs,选一次 worker 5.8 ns 到 2.24 µs。
  • vllm-project/router v0.1.16 的 chat 请求只用会话 id 当路由文本,不带会话 id 时 cache_aware 的前缀亲和不起作用。挡在 4 个假 worker 前,单个请求多 0.4–0.7 ms(cache_aware)到 1.06 ms(rendezvous_hash,16 KiB);并发 1 时所有请求落在同一个副本,并发 64 时按负载均匀分开。真实引擎上的命中率和 TTFT、SMG 的开销待实测。

💬 关联讨论

发起讨论
加载中...