▶ https://lysandre-tokenizers-v1-header.static.hf.space
在机器学习工作流中,分词器(tokenizer)向来不是瓶颈。从计算量看,分词相对于流水线其余部分繁重的建模工作来说很轻。然而在某些场景下,它已迅速成为加速(或拖慢)机器学习工作的关键。
随着模型越来越快、工作负载不断扩大,这种平衡开始被打破。在海量数据集上训练、同时处理大量并发请求,或反复处理长输入,都可能给分词器带来足够大的压力,使它无法及时给模型喂数据。
这正是我们决定在即将发布的 tokenizers 第 1 版中重点关注性能的原因。分词应该很轻量,并且能随你的工作流一起扩展。你的 GPU 永远不该闲着等 CPU 完成分词。
本文将介绍 v1 为何比 v0.23 更快,而且往往快几十倍。
这项工作能够完成,完全得益于整个生态的支持。分词是开源社区非常活跃的领域,gigatoken、tiktoken、kitoken、tokie、fastokens、wordchipper 和 ai-tokenizer 等库,以及许多其他项目,都在不断拓展快速分词器的可能性。我们研读了这些成果,下文中的好几个思路正是因为别的项目证明了值得一试,才传到我们这里。
在这次重构之前,tokenizers 的性能远未达到应有的水平,因此为它贡献代码可能显得不太值得。通过这次重构,我们希望表明:我们打算把 tokenizers 做成一个值得投入贡献的库。
我们还要感谢 IBM、NVIDIA 和 ExecuTorch 团队,他们贡献了补丁,并帮助我们在各种硬件上测试,以扩大平台支持范围。
结果
我们展示了 tokenizers v1 候选发布版与其他常用方案的对比结果,涵盖单线程、多线程、跨线程扩展、按模型对比、按语言对比、延迟、解码吞吐量、堆内存以及 crate 体积。
测试在 tokbench 仓库中运行,我们还添加了一条命令,方便你在自己的硬件上重新运行这些基准测试。
▶ https://lysandre-tokenizers-v1-results.static.hf.space
V1 是什么
v1 会生成与 v0.23 相同的 token ID。我们的目标是保持输出、API、词表和合并排名不变,并改进一切可以改进的地方,其中也包括覆盖面。这个库仍然面向各类分词器通用,而不是专攻 BPE,因此 v1 能加载 v0.23 能加载的一切。
分词器把文本转换成模型读取的整数列表。tokenizers 分四个阶段完成这一转换。归一化(Normalization)对原始文本执行小写化或 Unicode 归一化等操作。预分词(Pre-tokenization)把文本切分成称为 pre-token 的较小片段。模型(Model)阶段把每个 pre-token 转成 token,并映射为词表中的 ID。后处理(Post-processing)添加模型所需的特殊 token。
本文所述的大部分工作都发生在模型阶段。文中测量的十个模型家族里,有八个使用字节对编码(byte pair encoding,BPE)。BPE 从 pre-token 的字节出发,反复合并排名最高的相邻字节对,直到没有带排名的字节对为止。排名在分词器训练时学得,并随分词器一起发布,因此同样的文本总是产生同样的 ID。合并绝不会跨越 pre-token 的边界。另外两个家族使用 WordPiece 和 Unigram,这是该库支持的另外两种模型类型。
分词流水线页面记录了这四个阶段,分词算法页面记录了 BPE、WordPiece 和 Unigram。
▶ https://lysandre-tokenizers-v1-pipeline.static.hf.space
每个阶段都做了优化。以下是起关键作用的改动:
| 改动 | 作用 |
|---|---|
| workspace 拆分 | 单个 crate 变成一个 workspace:tk-encode 是必需的运行时,而 tk-serialize、tk-convert 和 tk-train 只有在应用需要时才会链接 |
| 无分配模型 | 合并所用的工作集放在调用方持有的临时缓冲区里,循环从不触碰内存分配器 |
| bitcannon | 切分模式变成对位流的布尔运算,用 SIMD 指令而非正则引擎来查找切分点 |
| 合并循环重写 | 待合并的各片段在一块预分配缓冲区内组成侵入式双向链表,因此一次合并只需更新两个索引,而无需移动数据 |
| 词缓存 | 线程本地的备忘表,把 pre-token 字节映射到最终 ID,重复出现的词只需合并一次 |
| 原生并行 | 一个共享的分词器可被多个线程同时用于编码;每个线程从自己的子池中获取临时缓冲区和词缓存,线程不再为同一把锁排队(#2365) |
切分:用位流取代正则表达式
BPE 模型使用正则表达式把输入文本切分成更小、更易处理的块,即 pre-token。合并发生在 pre-token 内部,绝不会跨越两个 pre-token 之间的边界,所以这一步切分决定了流水线其余部分看到的内容。
这个正则表达式是模型的固定参数。它随分词器一起发布,运行时从不改变,因此没有必要在每次编码时都用通用正则引擎去解释它。针对某个模型实际使用的模式,完全可以一次性手写一个等价的切分函数。
手写函数随后可以利用现代 CPU 的 SIMD 指令(single instruction, multiple data,单指令多数据)。这类指令一次对多个字节执行同一操作,非常适合 UTF-8 文本。bitcannon 把输入字节看作并行的比特流,于是边界可以通过对整个寄存器的布尔运算得出,而不必逐字符推进扫描。它每次寄存器操作可判定 64 个字节。Parabix 用于文本处理、simdjson 用于 JSON,背后是同一种思路。
这依赖于对模式的识别。少数几种语法就能覆盖大多数字节级 BPE 模型,模式不在其中的分词器仍走正则路径,享受不到这一加速。这正是上面各项提升幅度差异如此之大的原因。
▶ https://lysandre-tokenizers-v1-split.static.hf.space
词缓存
真实文本中有大量重复的词。由于 BPE 对给定的 pre-token 总是产生同样的 token ID,v1 可以在处理一次之后保存结果。线程本地缓存把每个 pre-token 的字节映射到其 token ID,使之后再次出现时可以跳过合并过程。
自然地,随着输入变长,不重复词的数量增长得比词的总数更慢,重复词在输入中所占的比例也就越来越高。新词依然会出现,这就是下面动画中偶尔出现未命中的原因。
▶ https://lysandre-tokenizers-v1-cache.static.hf.space
可以用以下命令复现共享前缀的结果:
tokbench measure prefix-sharing \
--engine pipeline \
--engine hf-tokenizers \
--compare-to pipeline-no-cache \
--corpus agentic_swe
当输入包含重复的 pre-token 时,缓存效果最好。如果输入中几乎没有重复的 pre-token,就可能白白付出查找的开销,却得不到多少命中。
合并循环
下一项主要开销来自 BPE 合并循环。对每个 pre-token,循环都要反复找出优先级最高的相邻字节对并合并。之前的实现每次调用都分配新内存,并为每个 pre-token 构建新的优先队列。
v1 复用由调用方持有的临时缓冲区,消除了这些重复分配。它把符号存放在一个扁平数组里,并通过符号在数组中的位置把相邻符号链接起来,使合并过程中的更新开销更低。它还在一次模型调用中处理一批 pre-token。
每个候选字节对还被打包进一个 64 位值,合并排名放在高位。比较两个候选就只是比较两个整数,而“此处无可合并”则是最大的可能值,因此循环无需分支就能找到下一次合并。
方法
基准测试设计上的细微差异,可能让分词器性能产生巨大差别。为了让各引擎之间的比较保持一致,我们采用了以下规则。
| 规则 | 原因 |
|---|---|
| 统一计时循环 | 每个引擎运行完全相同的循环,没有针对特定引擎的快速路径 |
| 排除加载 | 词表加载单独计时,绝不计入编码耗时 |
| ID 哈希校验 | 输出 ID 的 FNV-1a 哈希必须与基线完全一致 |
| 仅取共同单元格 | 中位数只在每个引擎都运行并通过校验的单元格上计算 |
| 每个进程完整扫描 | 每次重复都在新进程中开始,并保留每个单元格的结果 |
| 绑定物理核心 | 工作线程绑定到八个不同的物理核心,绝不使用同一核心的 SMT 兄弟线程 |
| 独立 Job | 用单独的 Job 衡量主机之间的差异 |
在同一构建上,反复编码同一份文档可能比编码一连串不同的文档更快。前一种方式测量的是整份文档已全部在缓存中时的性能,后一种方式测量的是新输入上的性能,同时允许之前见过的 pre-token 留在缓存里。
这两种情形有时都被称为“热”(warm),尽管它们测量的是不同的工作负载。我们的主要结果使用不同的文档,并且完整语料太大,无法装进缓存。分词器基准测试应当说明采用的是哪种工作负载,因为这一选择可能主导结果。
综合起来
在 v1 编码路径所覆盖的十个模型家族上,使用单线程在 Apple M4 Max 上,v1 的文本编码速度是 v0.23 的 3 到 30 倍。最低是 t5-base,最高是 gpt2。在八个工作线程上,它的扩展率达到线性的 76%。在所有这些改动中,v1 生成的 token ID 与已发布的库完全相同。
整体提升来自多项改动的协同作用:用手写切分器取代正则引擎,用缓存直接回答重复出现的词而无需再次合并,合并循环从不触碰内存分配器,以及对一批 pre-token 只做一次模型调用,而不是每个 pre-token 调用一次。每一项都在流水线的不同环节减少了工作量。
下一个优先事项是支持更多模型家族。在发布 1.0.0 之前,我们会把更多模型迁移到新的合并循环上。候选版本稳定之后,下一步是把这些改进带到 transformers 库以及依赖 tokenizers 库的其余生态中。
本文由 tokbench 的结果生成,并将随着支持范围的扩大而更新。
获取方式
v1 的候选发布版已上线 crates.io。你调用的 API 就是你已经在用的那个,唯一变化的是安装哪个构建。
安装方式与平常一样:
cargo add tokenizers --pre
训练功能位于一个默认开启的特性之后,它会引入一个 C++ 依赖。如果你只需要编码,可以将其关闭,从而排除训练实现:
cargo add tokenizers --pre --no-default-features --features http
编码保持不变:调用相同,ID 相同。
use tokenizers::tokenizer::{Result, Tokenizer};
fn main() -> Result<()> {
let tokenizer = Tokenizer::from_pretrained("deepseek-ai/DeepSeek-V4-Flash", None)?;
let encoding = tokenizer.encode("The tokenizer is no longer the bottleneck.", false)?; println!("{:?}", encoding.get_ids()); // [671, 17840, 9160, 344, 1119, 5827, 270, 111127, 16] println!("{:?}", encoding.get_tokens()); // ["The", "Ġtoken", "izer", "Ġis", "Ġno", "Ġlonger", "Ġthe", "Ġbottleneck", "."]
Ok(()) } ```
For a batch, `encode_batch` is what scales across cores. It is the call the scaling view above measures.
```rust
let encodings = tokenizer.encode_batch(documents, false)?;
本文中的每个数据都是针对这个 crate 测得的。Python 绑定封装的是同一份代码,由 bindings/python 构建,但它们会带来每次调用的额外开销,这些测量都未包含这部分开销。
迈向 V1 的进展
本文的基准测试涵盖了首先列出的、已完成的候选发布版工作。其余章节说明了 1.0.0 之前仍需完成的事项,以及我们计划在此之后探索的方向。
候选发布版:已实现
这些工作已包含在 crates.io 上的 Rust 预发布版中:
cargo add tokenizers --pre
- workspace 拆分:把单个 crate 拆分为
tk-encode、tk-serialize、tk-convert和tk-train,使应用只链接它用到的部分 - bitcannon:在编码路径上用位流操作取代正则切分,覆盖 GPT-2、cl100k、o200k、Tekken 和 DeepSeek。它取代了最初发布的有限状态机 #2201 #2317
- WordCache:复用先前已处理 pre-token 的 token ID #2262、
af5a3e3 - 更快的查找与合并结构:新增 FlatCache、MPHF RankStore、增量合并和 BucketVocabStore #2190 #2188
- 可复用的模型内存:把临时模型状态移入临时缓冲区,使分词在每次调用时无需分配内存 #2175 #2183
- 流水线后处理:把后处理作为
STAGE_POST流水线阶段对外提供 #2182 - 批量模型调用:一次调用处理多个 pre-token 区间 #2304
- 更快的解码:把解码出的字节直接写入可复用缓冲区,避免中间字符串和拷贝,加速 token 查找,支持带缓冲的流式处理,并行解码批量数据
role_to_token支持 #2343- Node.js 绑定 #2281
1.0.0
- 统一的编码实现:训练验证时使用
tk-encode,使训练与推理不会产生不同的分词结果 - 可选的偏移量和掩码:仅在被请求时才计算这些元数据,使仅需 token ID 的路径不受影响
- 重构归一化器
- 支持 bitnorm,基于 atomnorm #2209
- spm precompiled
- 更简洁的 Python 绑定:减少加锁、包装类型和手写分派代码,同时保留子类化、序列化、自定义解码器、可变行为以及对 free-threaded CPython 的支持
- 面向 ExecuTorch 和 llama.cpp 的仅推理 C 与 C++ 绑定,后续可能提供 JVM、Swift 和 Go 绑定
1.0.0 之后
- tok-devices:探索 GPU 编码与批量解码,同时让文本和 token ID 一直留在设备上。解码器只需上传一次词表,并行计算输出位置,再在 GPU 上收集对应字节。这将是一个面向大批量场景的可选组件,仍需进一步原型验证和测量。
