jieba-rs 分词性能优化记录：提升 2.4 倍

2019 年 Paul Meng 写了一篇博客，把 jieba-rs 优化到比 cppjieba 快 33%。之后六年没怎么动过性能，也没人来报慢。

2025 年 5 月开始，有几个贡献者对 hot path 动了手，我也顺手参与了进来。前后一年，七个 PR，HMM 分词从 2.85 µs 降到 1.32 µs，非 HMM 从 2.21 µs 降到 0.94 µs。2.16 倍和 2.35 倍。

单个改动都不算巧妙，大部分看完会觉得"早该这样"。叠在一起效果就很明显了。

怎么测的

每个 commit 单独 checkout，cargo bench，记中位数。机器、输入、allocator 全程不变：

Apple M4 Max
128 GB RAM
macOS 26.4.1
Rust nightly, jemalloc, criterion

测试句子：

我是拖拉机学院手扶拖拉机专业的。不用多久，我就会升职加薪，当上CEO，走上人生巅峰。

典型的中文句子，有标点有英文缩写，搜索引擎索引器喂进来的差不多就这样。

完整数据：

Commit | 改动 | no_hmm | with_hmm | cut_for_search
v0.7.2 | 基线 | 2.21 µs | 2.85 µs | 3.33 µs
2f06908 | lazy_static! → thread_local!，captures → find | 2.17 µs | 2.82 µs | 3.35 µs
2ff9cca | HMM 工作内存复用 | 2.11 µs | 2.90 µs | 3.37 µs
57b1a29 | 借用切片代替拷贝 | 1.99 µs | 2.78 µs | 3.24 µs
46fdac7 | thread_local HmmContext，容量预分配 | 2.02 µs | 2.81 µs | 3.27 µs
51cb0e1 | Vec 替代 HashMap，DAG 中打包 word_id | 1.45 µs | 2.09 µs | 2.59 µs
1f4e325 | 干掉正则引擎 | 1.03 µs | 1.49 µs | 1.98 µs
9e3965b | char 键 emit 概率，预计算 ln() | 0.94 µs | 1.32 µs | 1.81 µs

前四个加起来大概 10%。#141 和 #144 各砍掉 25-29%。#145 拿到 9-13%。大头就在后面三个。

lazy_static! → thread_local!，captures → find (PR #122)

Runji Wang

jieba-rs 原来有四个编译好的正则放在 lazy_static! 里，每次访问走一次原子加载来检查初始化。换成 thread_local! 之后变成 TLS 查找，不需要原子操作。

同一个 PR 修了几处本来用 Regex::captures() 但只需要 Regex::find() 的地方。regex crate 文档写得很清楚：is_match > find > captures，每升一级多做一些记录工作。

改动前：

lazy_static! {

static ref RE_HAN_DEFAULT: Regex = Regex::new(r"...").unwrap();

}

let splitter = SplitMatches::new(&RE_HAN_DEFAULT, sentence);

改动后：

thread_local! {

static RE_HAN_DEFAULT: Regex = Regex::new(r"...").unwrap();

}

RE_HAN_DEFAULT.with(|re_han| {

let splitter = SplitMatches::new(re_han, sentence);

// ...

})

跑出来差 1-2%，和噪声差不多。但做法本身没问题，后面几个 PR 也依赖这个改动。

HMM 工作内存复用 (PR #123)

Runji Wang

Viterbi 解码器每次调用分配三个 Vec：v（概率矩阵），prev（回溯指针），best_path。一个 C 字符的句子配 4 个 HMM 状态，就是三次分配，大小 4*C、4*C、C。单次不大，每个句子都来一遍就累积起来了。

改成 HmmContext 结构体传进来复用：

pub(crate) struct HmmContext {

v: Vec<f64>,

prev: Vec<Option<State>>,

best_path: Vec<State>,

}

clear() + resize() 代替每次重新分配。只有当前句子比之前最长的还长，allocator 才会被调用到。

微基准测试看不出差别，它就一个句子反复跑。实际场景分词几千个不同长度的句子时，省下来的分配开销就有意义了。这也是后面 PR #127 把 context 放到 thread_local 的前提。

借用 slice 代替拷贝 (PR #125)

aniaan

SplitState 枚举有个 into_str() 方法，某些路径上会拷贝匹配到的文本。重构后所有变体直接从输入字符串借用 &str，零拷贝。

改动量不大，lib.rs 里 30 行。但 no_hmm 快了 5-6%，with_hmm 快了 3-4%。内层循环切了上千次字符串，每次少一次拷贝就是这个效果。

thread_local HmmContext 和容量预分配 (PR #127)

PR #123 让 HmmContext 可以复用了，但每次 cut() 还是创建一个新的。这个 PR 放到 thread_local!，同一个线程内跨调用复用：

thread_local! {

static HMM_CONTEXT: RefCell<HmmContext> = RefCell::new(HmmContext::default());

}

// cut() 里：

HMM_CONTEXT.with(|ctx| {

let mut hmm_context = ctx.borrow_mut();

self.cut_dag_hmm(block, &mut words, &mut route, &mut dag, &mut hmm_context);

});

同一个 PR 给 TextRank 和 TfIdf 里几个 Vec::new() 加了 with_capacity()，StaticSparseDAG 的容量乘数从 5 调到 4，加了最小值 32。

cut 基准测试上没看出差别。容量预分配对关键词提取路径更有用，但 benchmark 没覆盖到。

Vec 替代 HashMap，DAG 中打包 word_id (PR #141)

从这个 PR 开始数字动了。所有 cut 模式快了 17-29%，改了四个地方。

最大的一个：StaticSparseDAG 用 HashMap<usize, usize> 存字节偏移到边数组位置的映射。字节偏移就是从 0 开始的连续整数，对这种 key 用 HashMap 完全没必要。直接 Vec<usize> 按下标访问：

// 改动前

start_pos: HashMap<usize, usize>,

// 改动后

start_pos: Vec<usize>, // 下标 = 字节偏移，值 = 边数组中的位置

空位存 usize::MAX 做 sentinel。clear() 时 fill(NO_ENTRY) 代替 HashMap::clear()。

第二个改动：DAG 的边里直接存 word_id。之前 DAG 只存 (byte_start, byte_end)，到了动态规划 calc() 阶段，每条边再做一次 cedar.exact_match_search() 查词频：

// 改动前：calc() 里重复查 trie

for byte_end in dag.iter_edges(byte_start) {

let wfrag = &sentence[byte_start..byte_end];

let freq = if let Some((word_id, _, _)) = self.cedar.exact_match_search(wfrag) {

self.records[word_id as usize].freq

} else {

1

};

}

改成 DAG 构建时就把 word_id 和 byte_end 用 u64 打包，calc() 直接拆出来用：

// 改动后：word_id 从边上直接拿

for (byte_end, word_id) in dag.iter_edges(byte_start) {

let freq = if word_id != NO_MATCH {

self.records[word_id as usize].freq

} else {

1

};

}

编码方式：byte_end + 1 放 u64 高 32 位，word_id（i32）放低 32 位，全零表示没有更多边了。

还有两个小改动。chars().count() == 1 换成 chars().nth(1).is_none()，前者遍历整个字符串计数，后者看一眼第二个字符就够了。HMM 路径里冗余的 re_han.is_match(block) 换成 state.is_matched()，split iterator 已经分类过了，没必要再跑一次正则。

干掉正则引擎 (PR #144)

PR #141 之后跑了一次 profiler，正则引擎还占 29% 的 CPU 时间。

这个数字让我有点意外。看看这几个正则在干嘛：RE_HAN_DEFAULT 匹配 CJK 字符加 ASCII 字母数字加几个标点，RE_SKIP_DEFAULT 匹配空白。没有回溯，没有量词嵌套，没有捕获组，就是判断一个码位在不在某个范围里。

regex crate 的 DFA 实现确实快，但通用引擎对这种需求来说还是太重了。换成 matches! 宏做字符分类，编译后就是几个范围比较，LLVM 直接变跳转表：

fn is_cjk(c: char) -> bool {

matches!(c,

'\u{3400}'..='\u{4DBF}'

| '\u{4E00}'..='\u{9FFF}'

| '\u{F900}'..='\u{FAFF}'

| '\u{20000}'..='\u{2A6DF}'

| '\u{2A700}'..='\u{2B73F}'

| '\u{2B740}'..='\u{2B81F}'

| '\u{2B820}'..='\u{2CEAF}'

| '\u{2CEB0}'..='\u{2EBEF}'

| '\u{2F800}'..='\u{2FA1F}'

)

}

fn is_han_default(c: char) -> bool {

is_cjk(c) || c.is_ascii_alphanumeric() || matches!(c, '+' | '#' | '&' | '.' | '_' | '%' | '-')

}

配套写了个 SplitByCharacterClass 泛型迭代器，接受 Fn(char) -> bool 分类函数，把文本分成 matched/unmatched 连续段，大概 30 行代码：

pub(crate) struct SplitByCharacterClass<'t, F> {

text: &'t str,

pos: usize,

classify: F,

}

逐字符扫描，classify(c) 为 true 就延伸 matched 段，为 false 就延伸 unmatched 段。类别翻转时产出当前切片，开始新段。

RE_SKIP_DEFAULT 的空白匹配更简单，tokenize 里直接逐字符遍历，\r\n 配对处理，连 splitter 都不需要。

唯一保留的正则是 hmm.rs 里的 RE_SKIP，匹配 [a-zA-Z0-9]+(?:.\d+)?%?，有量词和可选组，用正则合理。

结果：所有 cut 模式快了 25-29%。no_hmm 从 1.45 µs 到 1.03 µs。正则引擎从占 29% CPU 变成几乎没有。

char 键 emit 概率，预计算 ln() (PR #145)

干掉正则之后又看了一次 profiler。

Viterbi 内层循环还是热的。每个字符在 4 个 HMM 状态下查一次发射概率，用 phf::Map。key 类型是 &str，但每个 key 其实就一个汉字，UTF-8 编码 3 字节。每次查找在哈希 3 字节的字符串、逐字节比较。换成 phf::Map<char, f64> 后变成哈希一个 u32。

需要改生成 HMM 数据的 proc macro：

// 改动前

pub static EMIT_PROB_0: phf::Map<&'static str, f64> = ...;

// 改动后

pub static EMIT_PROB_0: phf::Map<char, f64> = ...;

另一个热点是 calc() 对每条边算 (freq as f64).ln()。词频在字典加载后就不变了，每次分词重算没有意义。Record 结构体加个 log_freq 字段，构造时算好：

struct Record {

freq: usize,

log_freq: f64, // (freq as f64).ln()，只算一次

tag: Box<str>,

}

calc() 直接读 self.records[word_id].log_freq。频率 1 的回退值也预计算成 log1 = 0.0 - logtotal，提到循环外面。

Viterbi 循环本身也简化了。之前用 Peekable 迭代器手动管理字节偏移来取每个字符的 substring：

// 改动前

let mut curr = char_offsets.iter().copied().peekable();

let x1 = curr.next().unwrap();

let x2 = *curr.peek().unwrap();

// ... 手动 peek/next

现在收集 char_indices()，下标循环就行了：

// 改动后

let chars: Vec<(usize, char)> = sentence.char_indices().collect();

for t in 1..C {

let ch = chars[t].1;

let em_prob = params.emit_prob(*y as usize, ch);

// ...

}

Vec 分配通过 HmmContext 复用摊还，很少真的走 allocator。

合起来 9-13%。with_hmm 从 1.49 µs 到 1.32 µs。

回头看

最重要的两个改动："连续整数 key 别用 HashMap"和"字符范围检查别用正则引擎"。这两个贡献了总提升的大部分，其余的单个不大，但叠加起来也有 10% 多。

每次都是 profiler 告诉我该看哪。不看火焰图我很容易猜错。正则引擎占 29% 这件事就很意外，那些 pattern 看着简单，regex crate 优化也到位了，但通用方案在需求简单到只需要几个范围比较的时候，还是比不过专用代码。

Rust 里写性能敏感代码，我对分配这件事反复犯同一个错误：热路径里 Vec::new() 在循环里面就是坏味道。挪出去，复用，resize。borrow checker 会烦你，但分配开销是实打实省下来的。

感谢 Runji Wang 和 aniaan 贡献了最初几个 PR。

爱发电上赞助