[
](https://huggingface.co#tokenizers-v1-encode-decode-and-scaling-measured)
tokenizers v1:编码、解码与扩展,实测
随着模型变得更快、工作负载不断扩展,这种平衡开始发生转变。在超大规模数据集上训练、服务大量并发请求,或反复处理长输入,都会给分词器带来足够大的压力,以至于让模型得不到数据。
这就是为什么我们选择在即将发布的 tokenizers 第 1 版中重点聚焦性能。分词应当轻量,并随你的工作流一同扩展。你的 GPU 绝不应闲置等待 CPU 完成分词。
在本文中,我们探讨是什么让 v1 比 v0.23 更快,而且往往快上数十倍。
这项工作之所以能够完全实现,要归功于生态中的其他项目。分词是一个非常活跃的开源领域,诸如 gigatoken、tiktoken、kitoken、tokie、fastokens、wordchipper 和 ai-tokenizer 等库,以及许多其他项目,各自都在推动快速分词器能达到的极限。我们研读了这些工作,下文中的若干想法之所以能来到我们这里,正是因为另一个项目证明了它们值得一试。
在这次重构之前,tokenizers 的性能远未达到它本可以达到的水平,因此为其做贡献可能看起来并不值得。通过这次重构,我们希望明确表明:我们有意让 tokenizers 成为一个值得贡献的库。
我们还要感谢 IBM、NVIDIA 和 ExecuTorch 团队贡献补丁,并帮助我们在各种硬件上进行测试,以拓宽平台支持。
[
](https://huggingface.co#results)
结果
我们展示了 tokenizers v1 候选版本与其他广泛使用的替代方案相比的结果。我们涵盖了单线程、多线程、跨线程扩展、按模型比较、按语言比较、延迟、解码吞吐量、内存堆,以及 crate 大小。
我们从 tokbench 仓库运行这些测试,并添加了一条命令,如果你愿意,可以在自己的硬件上重新运行这些基准测试。
[
](https://huggingface.co#what-v1-is)
V1 是什么
v1 将产生与 v0.23 相同的 token ID。目标是保留输出、API、词表和合并排名,并改进一切能够改进的地方。这包括广度。该库在各类分词器家族之间保持通用,而非专门针对 BPE,因此 v1 能加载 v0.23 能加载的一切。
分词器将文本转换为模型所读取的整数列表。tokenizers 分四个阶段执行这一转换。归一化对原始文本应用诸如小写化或 Unicode 归一化之类的操作。预分词将文本切分为更小的片段,称为预 token。模型将每个预 token 转换为 token,并将其映射到其词表中的 ID。后处理添加模型所期望的任何特殊 token。
模型阶段是本文所述大部分工作发生的地方。本文测量的十个模型家族中有八个使用字节对编码(byte pair encoding,简称 BPE)。BPE 从预分词(pre-token)的字节开始,反复合并排名最高的相邻对,直到没有可合并的排名对为止。排名在分词器训练时学习并随分词器一起发布,因此相同的文本总是产生相同的 ID。合并永远不会跨越预分词边界。另外两个家族使用 WordPiece 和 Unigram,这是该库支持的另外两种模型类型。
分词流水线页面记录了这四个阶段。分词算法记录了 BPE、WordPiece 和 Unigram。
每个阶段都做了工作。以下是重要的变更:
| 变更 | 作用 |
|---|---|
| 工作区拆分 | 一个 crate 变成了一个工作区:tk-encode 是必需的运行时,而 tk-serialize、tk-convert 和 tk-train 仅在应用程序需要时才链接 |
| 无分配模型 | 合并工作集位于调用者拥有的暂存缓冲区中;循环从不接触分配器 |
| bitcannon | 拆分模式变为位流上的布尔运算,使用 SIMD 指令查找拆分,而不是正则表达式引擎 |
| 合并循环重写 | 被合并的片段在一个预分配缓冲区内形成侵入式双向链表,因此一次合并更新两个索引,而不是移动数据 |
| 词缓存 | 一个线程本地的备忘录,从预分词字节到完成的 ID,因此重复的词只合并一次 |
| 原生并行 | 一个共享的分词器同时从多个线程编码;每个线程从自己的子池中获取其暂存缓冲区和词缓存,因此线程不再在单个锁上排队 ( |
[
](https://huggingface.co#the-split-bitstreams-instead-of-a-regex)
拆分:位流而非正则表达式
BPE 模型使用正则表达式将输入文本拆分为更小、更易于处理的块,称为预分词。合并发生在预分词内部,从不跨越两个预分词之间的边界,因此这个拆分决定了流水线其余部分看到的内容。
该正则表达式是模型的固定参数。它随分词器一起发布,在运行时从不改变,因此不需要通用正则表达式引擎在每次编码时解释它。可以针对给定模型实际使用的模式,手工编写一次等效的拆分函数。
然后,手工编写的函数可以使用现代 CPU 的 SIMD 指令(单指令多数据),这些指令一次对多个字节应用一个操作,非常适合 UTF-8 文本。bitcannon 将输入的字节视为并行的位流,因此边界来自整个寄存器上的布尔运算,而不是一次前进一个字符的扫描。它每次寄存器操作决定 64 个字节。同样的思路驱动了用于文本处理的 Parabix 和用于 JSON 的 simdjson。
这取决于识别模式。少数几种文法覆盖了大多数字节级 BPE 模型,而模式不在其中的分词器则保留正则表达式路径,无法获得这些加速。这就是为什么上述收益差异如此之大。
[
](https://huggingface.co#the-word-cache)
词缓存
真实文本包含许多重复的单词。由于 BPE 对给定的预分词总是产生相同的 token ID,v1 可以在处理一次后保存结果。一个线程本地缓存将每个预分词的字节映射到其 token ID,使得后续出现的相同预分词可以跳过合并过程。
自然地,随着输入的增长,唯一单词的数量可能比单词总数增长得更慢。重复单词在输入中所占的比例随之增加。新单词仍会出现,这解释了下方动画中偶尔的未命中。
使用以下命令复现共享前缀的结果:
tokbench measure prefix-sharing \
--engine pipeline \
--engine hf-tokenizers \
--compare-to pipeline-no-cache \
--corpus agentic_swe
当输入包含重复的预分词时,缓存效果最佳。重复预分词较少的输入可能付出查找成本却得不到多次命中。
[
](https://huggingface.co#the-merge-loop)
合并循环
下一个主要开销来自 BPE 合并循环。对于每个预分词,循环反复找到优先级最高的相邻对并合并它。之前的实现为每次调用分配新内存,并为每个预分词构建新的优先队列。
v1 重用了调用者拥有的临时缓冲区,消除了这些重复分配。它将符号存储在一个扁平数组中,并通过符号在该数组中的位置链接相邻符号,这使得合并期间的更新更廉价。它还在单次模型调用中处理一批预分词。
每个候选对也被打包成一个 64 位值,合并排名在高位。比较两个候选就只是比较两个整数,而“此处不合并”是可能的最大值,因此循环无需分支即可找到下一次合并。
[
](https://huggingface.co#method)
方法
基准测试设计中的微小差异可能导致分词器性能的巨大差异。我们使用以下规则来保持各引擎之间比较的一致性。
| 规则 | 原因 |
|---|---|
| 单一计时循环 | 每个引擎运行相同的循环;没有针对单个引擎的快速路径 |
| 排除加载 | 词汇表加载单独计时,从不包含在编码内 |
| 验证 ID 哈希 | 对输出 ID 进行 FNV-1a 哈希必须与基线完全匹配 |
| 仅公共单元 | 中位数仅基于每个引擎都运行并验证过的单元 |
| 每进程完整扫描 | 每次重复都在新进程中开始并保留每个单元 |
| 物理核心绑定 | 工作线程绑定到八个不同的物理核心,绝不使用兄弟 SMT 线程 |
| 独立作业 | 单独的作业测量主机间差异 |
在同一构建上,重复编码一个文档可能比编码一系列不同文档更快。第一种方法测量整个文档已在缓存中表示时的性能。第二种方法测量新输入的性能,同时允许之前见过的预分词保持缓存。
这两种情况有时都被描述为“热”,尽管它们测量的是不同的工作负载。我们的主要结果使用不同文档,而完整语料库太大,无法放入缓存。分词器基准测试应说明它们使用哪种工作负载,因为这一选择可能主导结果。
[
](https://huggingface.co#what-this-adds-up-to)
总结
在 v1 的编码路径覆盖的十个模型系列中,它在 Apple M4 Max 上单线程编码文本的速度比 v0.23 快 3 到 30 倍。最低端是 t5-base,最高端是 gpt2。它在八个工作线程上以线性扩展的 76% 进行扩展。在这些变化中,v1 产生的 token ID 与已发布库完全相同。
整体改进来自多项协同工作的变化:用手写分割器替代正则表达式引擎,用缓存回答重复单词而无需再次合并,合并循环从不接触分配器,以及每批预分词一次模型调用而非每个预分词一次。每一项都减少了流水线中不同环节的工作量。
下一个优先事项是支持更多模型系列。我们将在 1.0.0 之前将更多模型迁移到新的合并循环上。
一旦候选版本稳定下来,下一步将是在 transformers 库以及依赖 tokenizers 库的其余生态系统中带来这些改进。
本文基于 tokbench 的结果生成,并将随着支持范围的扩大而更新。
[
](https://huggingface.co#getting-it)
获取方式
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(()) } ```
对于批处理,`encode_batch` 才是能够跨核心扩展的调用。它正是上文扩展性视图所测量的调用。
```rust
let encodings = tokenizer.encode_batch(documents, false)?;
本文中的每个数据都是针对这个 crate 进行测量的。Python 绑定封装了相同的代码,并且是从 bindings/python 构建的
,但它们会增加每次调用的开销,而这些测量结果均不包含该开销。
本文中的基准测试涵盖了首先列出的已完成的候选发布工作。其余部分展示了 1.0.0
仍需要完成的内容,以及我们计划在此之后探索的方向。
这项工作已包含在 crates.io 上的 Rust 预发布版本中:
cargo add tokenizers --pre
- 工作区拆分:将单个 crate 拆分为
tk-encode
、tk-serialize
、tk-convert
和tk-train
,这样应用程序只链接它实际使用的部分 - bitcannon:将编码路径上基于正则表达式的拆分替换为覆盖 GPT-2、cl100k、o200k、Tekken 和 DeepSeek 的比特流操作。这取代了最初发布的有限状态机
#2201#2317 - WordCache:复用先前已处理的预分词的 token ID
#2262,af5a3e3
- 更快的查找与合并结构:新增 FlatCache、MPHF RankStore、增量合并以及 BucketVocabStore
#2190#2188 - 可复用的模型内存:将临时模型状态移入暂存缓冲区,使分词在每次调用时不再分配内存
#2175#2183 - 流水线后处理:将后处理暴露为
STAGE_POST
流水线阶段#2182 - 批量模型调用:在一次调用中处理多个预分词跨度
#2304 - 更快的解码:将解码后的字节直接写入可复用缓冲区,避免中间字符串和拷贝,加速 token 查找,支持缓冲流式处理,并并行解码批次
role_to_token
- 单一编码实现:在训练验证期间使用
tk-encode
,使训练和推理无法产生不同的分词结果 - 可选的偏移量和掩码:仅在需要时计算此元数据,使其不进入仅 token ID 的路径
- 重做规范化器
- bitnorm 支持,构建于 atomnorm 之上 #2209 - spm 预编译
- 更简单的 Python 绑定:减少锁、包装类型和手写分发代码,同时保留子类化、序列化、自定义解码器、变更行为以及对自由线程 CPython 的支持
- 面向 ExecuTorch 和 llama.cpp 的仅推理 C 和 C++ 绑定,后续可能跟进 JVM、Swift 和 Go 绑定
- tok-devices:探索 GPU 编码和批量解码,同时将文本和 token ID 保留在设备上。解码器将一次性上传词表,并行计算输出位置,并在 GPU 上收集相应的字节。这将是一个面向大批量的可选组件,有待进一步原型设计和测量。
