☰
用Rust重写SGLang运行时:Radix缓存、约束引擎与调度器的性能重构
2026/10/22 16:22:36 网站建设 项目流程

你问我为什么想不开,非要用 Rust 重写一个 SGLang 里的运行时。其实不是想不开,是跑线上服务跑出来的怨念。SGLang 的核心价值我很认可:radix cache(RadixAttention)解决长 prompt 的 KV 缓存复用、结构化输出约束解决 JSON 生成翻车的折磨、调度器把长短不齐的推理请求压进同一个 batch。但作为部署过几十个并发实例的人,我越来越清楚一件事:想法再好,落到 Python 过程里,实现层到处都是损耗。SGLang Rust 运行时不是要替代上游整个项目,而是把最疼的几条热路径——前缀缓存、约束机、批调度、token 级处理——下沉到 Rust 里,把 Python 当成总线而非引擎。

这篇文章写给两类人:一类是在思考“我是不是也该把推理中间层重写一遍”的研发,另一类是纯好奇底层原理、想趁机学线程级系统设计的同学。我不会卡在脚手架阶段,会直接拆到细节:radix 树为什么用Vec<Node>而非Box<Node>、DFA 约束 每次解码怎么不掉链子、批量调度怎么把 Linux 内存门限定住。看完你能拿去 mock 真实方案,也能避开我已经踩平的坑。

1. SGLang 运行时里到底藏了什么?为什么值得用 Rust 重写一遍?

1.1 SGLang 要做的事

先说大白话。SGLang 这类推理框架解决两个核心问题:第一,LLM 的 KV cache 能不能尽量复用,让“聊过的前缀”不再重新算;第二,输出能不能被约束成你要的结构,而不是让模型自由发挥。围绕这两件事,还需要调度器、tokenizer、显存管理、预热、日志、并发接入这些周边设施。

其中真正的复杂度集中在三个组件:

  • radix attention 缓存树:每条请求的前缀按 token 拆开,共享前缀尽量复用,遇到分叉点再生成新节点,空间不够时就淘汰。
  • 结构化输出引擎:把正则表达式或者 JSON Schema 编译成自动机,在解码的每一个 token 上前向检查,只允许那些“不会让最终结果偏离约束”的 token 进入候选集。
  • 批调度器:把不同到达时刻、不同输出长度、不同优先级的请求揉进同一个 batch,同时还要管理 KV cache 的占用和逐出。

这三个组件,在 Python 里能跑,但有非常现实的性能天花板。

1.2 Python 在哪些地方卡脖子

Python 不是不能做高性能,它更适合做“组织者”,不适合做“执行者”。我列出真正让人头大的四个点:

  • 对象模型开销。一整棵 radix 树如果每个节点都是 dict,每个 cache 命中都要做多次哈希查表、引用计数、深拷贝,这种开销在 token 级逻辑里会被放大几百倍。
  • GIL 的存在。同一时间只有一个线程跑 Python 字节码,token 校验、树更新、队列调度如果都压在 GIL 下,合起来就会吃光 CPU 转圈时间。
  • 二进制数据的搬运。token id 数组、attention mask、cache 统计指标,全部以 Python list 和 numpy 数组来回倒,numpy 虽然快,但数据进进出出要来回拷贝。
  • 调度和约束的一层层函数调用。一次解码要过is_allowed_token()、state_transition()、check_peak()这类函数,函数本身不慢,慢的是每一层都走一遍 Python 虚拟机和属性查找。

所以 Rust 的收益就很清楚了:用接近 C 的执行性能去跑这些高频小块。好比你以前是让数学博士每个数字都按计算器,现在直接把公式拍到他脑子里。

1.3 Rust 的收益边界:别指望它让模型跑得更快

这里必须先泼一盆冷水。Rust 重写运行时,不会让 GPU 上的 Transformer 算子变快。矩阵乘法、flash attention、多卡通信这些,真正吃时间的大头不在我们的掌控范围内。Rust 的价值在“刀口以外的地方”:

  • 框架开销更低,CPU 侧吞吐提升 15% 到 30%。
  • 长 prompt 多轮对话时,缓存命中率和缓存回收粒度更细,KV 显存使用更可以有效。
  • 结构化解码峰值更稳,P99 延迟比 Python 实现有明显改善,不会因为 GC 或 GIL 而抽风。

如果把整条服务链路比作流水线,GPU 是压路机,CPU 侧是流水线入口处的分拣员。分拣员再快,压路机也不能打得更快;但如果分拣员双手在抖,整条线就一定快不了。

2. 整体架构:把 Python 留在门口,热路径交还给 Rust

2.1 模块划分:引擎、缓存、调度、约束、桥接

动手之前,我先把软件切块列清楚,避免写到一半在模块边界上迷路。

模块职责语言重要程度
scheduler请求排队、batch 组、优先级处理Rust核心
radix_cache前缀匹配、节点复制、引用计数、逐出Rust核心
constraint_engine正则/JSON Schema 编译、token 掩码Rust核心
tokenizer_bridgetokenizer 调用、token id 映射Rust/Python必要
py_bridge与 Python 推理框架交互、触发执行PyO3必要
monitor指标统计、日志上报Rust辅助

我特别想强调一个取舍:不要把所有东西都下沉到 Rust。例如:显存分配策略需要和具体的部署环境、GPU 驱动调度器联动,这块放 Python 里反而灵活。tokenizer 的一些特殊清理逻辑也会不断调整,如果全部用 Rust 实现,每次改动都变成编译和发版成本。为了一个高频、一个低频,尽量把 Rust 限制在热路径内部。

2.2 边界设计:只让低频操作留在 Python 侧

日常开发里最常见的错误,是把 Rust 与 Python 的分界线划在以“函数”为单位的地方,而不是以“请求”为单位。这会导致每一个操作都要走一遍图省的跨语言调用,成本反而更高。

我的设计是粗粒度跨语言调用:

  1. submit_session(context_ids, req_id):把一次整请求的所有输入一次性送给 Rust。
  2. step():批次内推理一步,触发一次完整的缓存查询、约束检查、调度决策。
  3. commit_tokens(tokens):推理框架输出 tokens 后,再一次性回写缓存状态。

这样一次请求只有几个跨语言边界,而不是每个 token 都跨过来跨过去。

2.3 数据流的一条清晰链路

你可以把数据流想象成一条运输带:

  1. Python 侧接收到 HTTP/WS 请求,进行鉴权、session 管理。
  2. 把 prompt token ids、请求元数据包成一个批次,通过 PyO3 的submit_session给 Rust。
  3. Rust 执行:查 radix 树 -> 得到复用序列和补算序列 -> 构造 batch -> 约束引擎生成候选掩码 -> 更新缓存统计。
  4. 结果回到 Python,Python 侧只需要调用模型后端,把 batch 跑掉。
  5. 模型后端返回 logits,Python 再回传给 Rust 做 token 选择 和 状态推进。

这套结构的好处是,Rust 核心可以独立测试、独立压测。我写单元测试时甚至不需要 Python,直接把 token 序列灌进 Rust 的 CLI 试验程序,几分钟就能验证正确性。

3. RadixAttention:prompt 缓存树的 Rust 实现

3.1 为什么前缀是唯一值得信的缓存甜点

LLM 推理里最贵的部分是早先的 prompt 处理和 KV cache 计算。多轮对话中后面的所有轮次,都能复用前面的 KV cache。SGLang 的方案是说:把每次请求的完整 token 序列按当前请求切分,组合成一颗 radix 树,树上每个节点代表一段共享前缀。请求 A 是 “我喜欢猫”,请求 B 是 “我喜欢狗”,两个请求共享 “我 喜欢” 这个前缀,那么这段内容对应的 KV cache 只需计算一次。

在 Rust 里的第一件事,就是把树节点设计成内存紧凑的结构。

3.2 用索引指针而非对象句柄的内存布局

如果你直接写Box<Node>建立树,你会很快在可变借用上跟借用检查器打起来。我推荐用一个Vec<Node>当对象池,然后用u32索引代替指针。Rust 的Vec在堆上是连续内存,遍历一级孩子节点时可以直接利用 CPU 缓存行。

节点长这样:

#[derive(Debug, Clone)] struct RadixNode { parent: Option<u32>, children: Vec<(u32, u8)>, // (child_node_index, 首 token 的字节) kv_ref_count: u32, // 引用计数,决定能否被逐出 last_accessed: u64, // 访问时间戳,LRU 淘汰用 token_span: (u32, u32), // 本节点在总 token 序列中的起始和结束位置 }

为什么要用(child_node_index, 首 token)这种组合?因为查找某个前缀的孩子时,我们期望快速按首 token 二分或哈希。如果直接拿Vec<Node>存,每次找孩子都要扫一遍,太浪费。更优化的做法是孩子存在一个HashMap<u32, Vec<u32>>里,首 token 作为 key。如果 Prompt 很长且孩子非常多,则可以考虑直接上BTreeMap,但实测里HashMap在几百个孩子时已经很够用。

3.3 匹配、分叉与逐出的核心逻辑

假设来了一个请求,要查询前缀["我", "喜欢", "猫", "的", "毛"]。整个过程:

  1. 从树的根节点出发,查看 children 中是否存在["我"]节点。存在,则命中。
  2. 再找这个孩子节点下是否有 token["喜欢"],存在,继续往下。
  3. 到第三个 token["猫"]时,发现原本节点只到["喜欢"],这里自己有两个孩子:["猫"]和["狗"]。
  4. 检查["猫"]节点存在,继续到 token["的"],发现["猫"]节点下没有对应孩子,于是创建新节点,承接余下部分的 token。

这里的关键优化:当一条长请求只命中了前 80% 时,我们尽量不要打碎原有节点。处理方式是节点分裂:把原有["猫"]节点分裂成["猫"]和["的"]两个节点,前者是后者的父亲。这样新请求和已缓存请求都能保留最大公共前缀。

逐出策略上,我用的是引用计数 + 时间戳双保险:每个节点初始kv_ref_count = 1,当一个请求的缓存被引用时计数加 1,请求释放时减 1。只有计数为 0 的节点才参与 LRU 淘汰。LRU 我以为用时间戳排序最直观,扫描全部节点太慢,于是我维护一个按last_accessed排序的 BTreeMap:BTreeMap<(u64, u32), u32>,用于快速定位最早未被引用的节点。实测一个 100 万节点的树,淘汰时只要遍历映射表前几十项,就够腾出内存了。

淘汰的最大坑是“级联逐出”。如果一个父节点被淘汰,所有子节点都失去了缓存意义,必须一并释放。此时不是一个个改计数,而是通过 DFS 找到整个子树后统一回收 KV 显存。我曾经在这里跳过子节点递归,结果显存泄漏像漏水的水管,排查了一整天才在审计日志里看到大量孤儿节点。

3.4 借用检查器在树上如何唠叨

写 radix 树的 Rust 代码时,我大概率被 Rust 编译器教做人了:Vec<Node>存索引最大的麻烦是,有时你想同时修改 node 和它的 parent。例如分裂节点时,既要改原节点内容,又要改父节点的 children。如果用可变引用同时访问两个元素,借用检查器会直接拍桌子。

我的解法是“先取索引,再做批量操作”。分裂操作不直接通过引用改,而是:

fn split_node(&mut self, node_idx: u32, split_offset: u32) -> u32 { let old_tail_span = { let node = &self.nodes[node_idx]; (node.token_span.0, node.token_span.1) }; // 先构建新节点,不改旧节点 let new_idx = self.new_node(split_offset, old_tail_span.1, node_idx); // 再把旧节点的尾部 span 缩短 let old_node = &mut self.nodes[node_idx]; old_node.token_span.1 = split_offset; new_idx }

借用检查器其实是在保护你:它逼你把“读旧数据”和“写新数据”分成两个阶段,才避免了那种你在 C 语言里经常踩的迭代失效 bug。

4. 结构化输出引擎:从正则到基于 token 的 FSM

4.1 解码约束,不是要什么给什么

很多人的第一反应是:“要 JSON 就加个‘请输出 JSON’的 prompt 呗。” 但实际效果和在悬崖边跳舞差不多,模型一激动就丢了右括号。SGLang 的约束引擎做法是硬约束:每当你得到一个 logits 分布,只有在约束自动机上处于“接受态”的 token 才允许被采样。这样,模型只能往前走合格路径,绝不允许跑到结构外。

Rust 实现的核心任务是“约束自动机的状态推进”和“token 掩码计算”。

4.2 自动机设计与 token 级状态推进

最基础的做法,是把正则表达式编译为 NFA/DFA,一个字符一个字符地走。但 LLM 的 tokenizer 并非按单字符切分,一个 token 可能是"JSON",也可能是“,还可能带空格。因此我们不能只检查一个字符,而要对每个候选 token 的完整字符序列进行自动机转移。

面向 tokenizer 的设计是这样的:

  • 为每一个 DFA 状态预先构建一个“允许字符前缀”二分表。
  • 解码时拿到一个候选 token,把它拆成字符序列,然后从当前状态出发依次转移。
  • 只要全部字符都在允许路径上,且最终 DFA 状态是非失败态(是接受态或可能扩展的中间态),就保留这个 token 的采样概率。

更进一步的加速是把 token 压缩成“前缀类型”。很多 token 的第一字符相同、中间字符相同,其实没必要逐个字符跑。我们可以给自动机做一次离线缓存:

struct StateTokenTransition { state_id: u32, token_id_list: Vec<u32>, // 此状态下允许的所有 token next_state: Vec<u32>, // 每个 token 转移到的目标状态 }

推理后端在采样前只需要查这张表。表太大时,可以压缩成“分块索引”:先按 token 的首字符建立 256 个桶,再用二分查桶内的子串。实测用这种“首字符桶 + 子串二分”的方式,128k 词表的扫描时间从几百微秒降到几十微秒。

4.3 长思维链(CoT)场景下的约束拆解

线上做 agent 的时候,经常遇到模型要先输出一大段“思考过程”,再输出最终 JSON。如果整个输出过程都拿正则约束住,思考过程就废了,模型会像被风湿一样一句话都不敢多说。我的处理方式是:把整个输出分段约束,前段只约束“不能有小括号未闭合”“不许提前输出 JSON 关键字”,后段进入严格 JSON 模式。分段之间不共享同一个自动机,但共享 token 掩码缓存。

这个“分段约束”在 Rust 的实现上就是给引擎传入一个Vec<ConstraintSegment>,每个 segment 有自己的编译表和起始状态。调度时引擎根据已生成的 token 数切换 segment。代码上很清爽:

pub enum ConstraintSegment { Free, // 无约束,仅过滤不可见字符 JsonSchema(JsonSchemaId), // 严格模式 Delay(RegexId), // 宽松正则 }

4.4 JSON Schema 与正则的落地差异

正则做自动机容易,JSON Schema 做起来就复杂。因为 JSON Schema 有嵌套对象、数组、枚举、条件 if/then/else。直接套正则等于重写一个 JSON 解析器。SGLang 原本的处理是把 Schema 转化成“结构自动机”,每次解码时按当前节点类型(对象、数组、字符串、数字、布尔值、null)决定允许哪些 token。

Rust 这边我采用“栈式 FSM”而不是单状态 FSM。每进入一个对象或数组,压栈一个新的状态机;每完成一个 token,从顶部分弹出。比如:

enum JsonFSMState { StartObject, ObjectKey(StringState), ObjectValue(ValueType), ArrayElements(ValueType), EndValue, }

每次采样结束后不只在单一状态上推进,还要更新一个状态栈。这种实现没有正则那么“公式化”,但是更可控。遇到复杂的嵌套结构时,用内存空间换逻辑正确性,非常划算。

5. 调度、批处理与 KV 管理的协同设计

5.1 为什么需要分布式 token 预算

推理服务最理想的状态,是批处理里的每个请求都差不多同一时刻结束。可惜现实世界永远有人闲有人忙,一批请求里可能有一个在写长篇小说,另外三个只需要一个词。如果不去管它,长请求会无限占用 batch 位置,短请求吃不到显存,整个吞吐被拖垮。

调度器的核心指标是“系统总 KV 内存预算”。我的设计不是每个请求独占固定显存,而是有一个全局预算。Rust 调度器在每一轮step()开始前,对当前批次里的所有请求做 token 配额分配。

分配逻辑分两层:

  1. weighted round-robin:每个请求根据其还剩多少预估输出长度,按权重去占 batch 的容量。
  2. KV 上限保护:一旦总 KV 内存快满了,就优先淘汰引用计数为零且已完成主体输出的节点。

这个设计的价值在于,模型后端显存不够时,不是直接 OOM,而是提前把一些“没说几句话但对缓存无贡献”的请求挤出 batch,显存可以立刻被新请求复用。

5.2 引发“过期缓存”与“重新验证”博弈

如果调度器太激进,把某些请求的 KV 当缓存逐出了,那么这一轮的输出完了之后,后续继续生成时又得重新计算前缀。这叫做“缓存过期”。没人在意这个词,但代价是最直观的:重新计算可能比省下的时间还要贵。

所以我在调度时做了一次“重新验证”。当一个请求的 KV 节点引用计数为 0 且将要被逐出,但该请求尚未结束生成,调度器会先把该请求标记为 high_priority。这一轮绝不驱逐它。所谓 high_priority 判定,是参考当前 batch 里所有请求的总剩余长度以及驱逐后重新计算的预估开销。

这里的关键经验是:不要只看完成比例,要看剩余 token 数。一个已经完成 80% 但还剩 2000 token 的请求,它的缓存价值远比一个完成 20% 但只剩 100 token 的请求要低。我编码成了cache_value_score。

5.3 batch 突变:一个请求中途插队怎么办

假设当前 batch 已经填满,来了一个新请求,它的前缀恰好全部命中缓存。此时把它加进 batch 几乎零成本,甚至能提高整批收益。调度器需要支持“前缀命中请求免排队插队”。

具体实现:

  1. 检查新请求的前缀 radix 命中率,如果命中率超过 80%,进入 preempt queue。
  2. 如果 batch 中有一个请求已处于收尾阶段(只剩很少 token),直接从 batch 中把它替换为 preempt queue 中的请求。
  3. 用一次cache_value_score对比,来保证插队不会亏本。

这个场景很适合用 Rust 的crossbeam_channel来做队列管理,把调度决策和推理执行破成两个独立线程。调度线程在一个无锁队列上取请求、做缓存查询、组 batch;推理线程在 GPU/推理后端上执行。并行度高了,延迟抖动自然也低。

6. 通过 PyO3 把 Rust 核心接回 Python

6.1 薄封装层的应用方式

写 PyO3 绑定的时候,我强烈建议把它做成“门后面的一层薄箱子”。不要在绑定层写复杂逻辑,只做三件事:数据转换、函数转发、类型转换。

我用 maturin 管理项目,pyproject.toml只需要一个 module 定义,比如:

[build-system] requires = ["maturin>=1.0,<2.0"] build-backend = "maturin"

然后在 Rust 里定义:

#[pymodule] fn sglang_rs(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::<RadixCache>()?; m.add_class::<Scheduler>()?; m.add_class::<ConstraintEngine>()?; Ok(()) }

Python 侧拿到的是一个sglang_rs.RadixCache对象,可以直接在业务代码里调用。但我要强调,数据交换的类型尽量用Vec<u32>、Vec<i64>这类原生数字数组,通过PyBytes或numpy的 buffer 协议传递。它是最高效的桥接,不会因为 Python int 和 Rust i64 的转换而拖后腿。

我的做法是:在 Rust 里导出add_sequence(seq_bytes: &[u8]) -> u64,其中seq_bytes是打包成字节数组的 token ids。然后在 Python 侧用numpy.tobytes()生成这个输入,一次性投喂。结果一次性拉回。

6.2 粗粒度锁和多线程注意的点

PyO3 绑定最大的问题不是性能,而是 GIL 和线程安全。如果你在每个函数调用里都打开 Python 的 GIL,那多线程的优势就没了。

我的设计是:Rust 核心持有一条内部消息队列,Python 侧在给队列发请求后立刻释放 GIL,Rust 调度线程在后台处理。Python 侧只在以下几个时机重新获取 GIL:

  • 步骤结束,需要拿 logits 结果时。
  • 缓存统计需要上报给监控时。
  • 有请求需要从 Python 侧获取元数据时。

这样就很少会在 Rust 和 Python 之间来回横跳,也不会把 GIL 拽进高频循环。

不过要提醒的是,PyO3 底层对象的生命周期要格外小心。如果你把某个 Python 对象传给 Rust 并留在 Rust 侧长期保存,必须确保引用的持有不会造成 Python 侧对象无法回收。我大多数时候只传bytes和数字,避免把复杂 Python 对象传给 Rust,省得后面内存泄漏和调试到怀疑人生。

7. 我踩过的一些坑与问题排查实录

7.1 调优时最容易犯的低级错误

现象根因解决方案
缓存命中率上去了但整体吞吐不变逐出过于保守,KV 显存一直顶在峰值调小缓存窗口,允许动态逐出
单条请求延迟很低,整体批吞吐崩了每个 batch 都在等待最慢请求增加长输出请求的超时驱逐逻辑
约束引擎把正确 token 拒掉了自动机状态转移实现里用了全局 BFS改成栈式 FSM,压力测试后正确性提升
Rust 核心不崩溃但 Python 进程暴涨缓存节点引用计数泄漏用kv_ref_count审计日志,查每个节点增减是否成对

7.2 做性能压测时注意别被“假快”骗了

所有人都喜欢拿一条 100 token 的请求说“咱们延迟好低”。真实生产环境根本不是这样。我做压测时一定会混合三类请求:超短输出、普通输出、超长 CoT 输出。并且把请求的间隔调成不均匀泊松分布,模拟多个 App 用户同时发消息的节奏。

更有意思的是,默认情况下 Python 版本里结构化解码会非常吃 CPU,Rust 版本的结构化约束时间可以从每 token 1ms 降到 0.1ms 以内,但整体 GPU 前后效果不大。这个结论一点都不稀奇:GPU 是整个系统的瓶颈,CPU 侧省下的时间必须用来做“更多请求的排队”,才能变成吞吐收益。别在压测报告里炫耀“约束引擎快了 10 倍”,要炫耀“在相同 GPU 显存下, P99 延迟降了 40%”。

7.3 树结构到底要不要内存池

我一开始觉得Vec<Node>就足够,后来发现高并发下节点增删过于频繁,Rust 默认分配器在高频 alloc/dealloc 时还是有点抖。优化方案是把节点回收放到一个 free list 里,删除节点时不释放内存,只是把节点编号压回空闲队列;新节点分配时优先从空闲队列里拿。

fn free_node(&mut self, index: u32) { self.nodes[index].children.clear(); self.nodes[index].parent = None; self.free_list.push(index); }

这个改动让高性能场景下的分配抖动了大约 20%。同样的思路也用在 token 缓冲区上,把频繁扩容的Vec<u32>预先分配好,后续直接覆盖写。

8. 我个人在实际操作中的体会

整套折腾下来,最大的感受是:Rust 不会自动让系统变快,但会强制你把性能问题想清楚。树、自动机、调度这些概念在 Python 里写出来很容易,可一上负载就露馅,因为没有人在意每一层调用到底多贵。用 Rust 重写一遍,相当于把整条链路拆开、擦拭、再装回去,很多以前忽略的设计缺陷都会清晰暴露出来。

如果你也想做类似的事情,我给的建议是:不要上来就写引擎,先写一个 300 行左右的 radix 缓存原型。让它在纯 CPU 环境下能吃下几万条短请求,观察内存布局和 cache 命中率。等这一关过了,再往里加结构化解码。一次只加一件事,每件事都配好 metrics。相信我,你会很快在调试日志里看到惊喜的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询