返回 文章 apply CMS 文章

tokenizers v1 实测:编码、解码与扩展性能大幅提升

tokenizers v1 通过多项底层优化,在不改变输出 token ID 的情况下,将编码速度提升 3-30 倍,并显著改善多线程扩展性。

tokenizers性能优化RustHugging Face
成长分 / 100 76 综合收获、行动、留存与影响

tokenizers v1 实测:编码、解码与扩展性能大幅提升
为什么值得读了解 tokenizers v1 如何通过位流拆分、词缓存、无分配合并循环等具体技术实现数量级性能提升。

获取在 Apple M4 Max 上单线程快 3-30 倍、八线程线性扩展 76% 的实测数据,评估升级价值。

关键洞察
  1. v1 将正则表达式拆分替换为基于 SIMD 的位流操作(bitcannon),每次寄存器操作处理 64 字节,覆盖 GPT-2、cl100k、o200k、Tekken 和 DeepSeek 等模型。
  2. 引入线程本地词缓存,将预分词字节映射到 token ID,重复单词只需合并一次,显著减少重复计算。
  3. 合并循环重写为使用调用者拥有的暂存缓冲区,避免每次调用分配内存,并将候选对打包为 64 位整数,实现无分支比较。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:15791

[

](https://huggingface.co#tokenizers-v1-encode-decode-and-scaling-measured)

tokenizers v1:编码、解码与扩展,实测

在 GitHub 上更新

随着模型变得更快、工作负载不断扩展,这种平衡开始发生转变。在超大规模数据集上训练、服务大量并发请求,或反复处理长输入,都会给分词器带来足够大的压力,以至于让模型得不到数据。

这就是为什么我们选择在即将发布的 tokenizers 第 1 版中重点聚焦性能。分词应当轻量,并随你的工作流一同扩展。你的 GPU 绝不应闲置等待 CPU 完成分词。

在本文中,我们探讨是什么让 v1 比 v0.23 更快,而且往往快上数十倍。

这项工作之所以能够完全实现,要归功于生态中的其他项目。分词是一个非常活跃的开源领域,诸如 gigatokentiktokenkitokentokiefastokenswordchipperai-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-serializetk-converttk-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 构建的

,但它们会增加每次调用的开销,而这些测量结果均不包含该开销。

迈向 V1 的进展

本文中的基准测试涵盖了首先列出的已完成的候选发布工作。其余部分展示了 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 #2262af5a3e3

  • 更快的查找与合并结构:新增 FlatCache、MPHF RankStore、增量合并以及 BucketVocabStore #2190#2188 - 可复用的模型内存:将临时模型状态移入暂存缓冲区,使分词在每次调用时不再分配内存 #2175#2183 - 流水线后处理:将后处理暴露为 STAGE_POST

流水线阶段#2182 - 批量模型调用:在一次调用中处理多个预分词跨度 #2304 - 更快的解码:将解码后的字节直接写入可复用缓冲区,避免中间字符串和拷贝,加速 token 查找,支持缓冲流式处理,并并行解码批次 role_to_token

支持#2343- Node.js 绑定 #2281

1.0.0

  • 单一编码实现:在训练验证期间使用 tk-encode

,使训练和推理无法产生不同的分词结果 - 可选的偏移量和掩码:仅在需要时计算此元数据,使其不进入仅 token ID 的路径

  • 重做规范化器
  • bitnorm 支持,构建于 atomnorm 之上 #2209 - spm 预编译
  • 更简单的 Python 绑定:减少锁、包装类型和手写分发代码,同时保留子类化、序列化、自定义解码器、变更行为以及对自由线程 CPython 的支持
  • 面向 ExecuTorch 和 llama.cpp 的仅推理 C 和 C++ 绑定,后续可能跟进 JVM、Swift 和 Go 绑定

1.0.0 之后

  • tok-devices:探索 GPU 编码和批量解码,同时将文本和 token ID 保留在设备上。解码器将一次性上传词表,并行计算输出位置,并在 GPU 上收集相应的字节。这将是一个面向大批量的可选组件,有待进一步原型设计和测量。