tokenizers v1 性能实测:编码解码提速最高达30倍

0 阅读

tokenizers v1 的核心目标:更快,但结果不变

在机器学习工作流中,分词器(tokenizer)过去很少成为瓶颈。相比模型训练或推理的巨大计算量,文本转 token 的开销显得微不足道。然而,随着模型越来越快、数据集越来越大、并发请求越来越多,这个平衡正在被打破。当 GPU 饿着肚子等 CPU 慢悠悠地做完分词时,整个系统的效率就被拖垮了。

为了解决这个问题,Hugging Face 团队将即将发布的 tokenizers v1 版本的核心目标定为:极致的性能。但有一个硬性前提——v1 必须产生和 v0.23 完全相同的 token ID。这意味着 API、词汇表、合并规则都不能变,所有优化都必须在不改变最终结果的前提下进行。

这次重构并非闭门造车。团队深入研究了 gigatoken、tiktoken、kitoken 等一系列开源高性能分词库的工作,从中汲取了大量灵感。他们的目标很明确:让 tokenizers 成为一个值得社区持续贡献的、真正高效的底层库。

关键性能提升点解析

v1 的性能飞跃并非来自单一“银弹”,而是多个关键组件协同优化的结果。这些改动覆盖了分词流水线的各个环节。

用位流操作取代正则表达式分词

对于 BPE(字节对编码)这类主流分词算法,第一步是将原始文本切分成更小的单元,称为“预分词”(pre-token)。传统做法是使用一个固定的正则表达式来完成这个任务。问题在于,每次分词都要调用通用的正则引擎去解释同一个模式,这本身就是一种浪费。

v1 引入了一个名为 bitcannon 的新方法。它针对模型实际使用的那几个固定分词模式(如 GPT-2、cl100k 等),手写了一套高效的分割函数。这套函数利用现代 CPU 的 SIMD(单指令多数据)指令集,将输入的字节视为并行的位流,通过位运算一次性处理多达 64 个字节。这种方法跳过了逐字符扫描的低效过程,速度自然大幅提升。当然,如果你的模型使用了非常规的分词模式,它还是会回退到原来的正则路径,这也是不同模型提速幅度差异巨大的原因之一。

引入线程本地的词缓存

现实世界的文本充满了重复的单词。既然 BPE 算法对于相同的输入总是产生相同的输出,那么何不把结果缓存起来?v1 在每个线程内部维护了一个 词缓存(Word Cache)。当一个预分词单元(比如一个单词)第一次被处理时,它的最终 token ID 列表会被存下来。下次再遇到同样的单词,就可以直接从缓存中读取结果,完全跳过复杂的合并(merge)过程。

这个优化在处理包含大量重复词汇的长文本时效果尤为显著。随着输入文本的增长,新词出现的频率会逐渐降低,缓存命中率随之升高,整体分词速度也就越来越快。

彻底重写的合并循环

BPE 的核心是合并循环:它从预分词的字节序列开始,不断寻找优先级最高的相邻字节对并将它们合并,直到无法再合并为止。在旧版本中,这个过程效率低下:每次处理一个预分词都要重新分配内存,并为它构建一个新的优先级队列。

v1 对此进行了彻底重构。首先,它引入了调用者拥有的暂存缓冲区(scratch buffer),避免了在循环内部反复进行内存分配。其次,它将待合并的符号存储在一个扁平数组中,并通过索引在数组内部构建了一个侵入式的双向链表。这样,一次“合并”操作就简化为更新两个索引值,而不是移动大块数据。此外,v1 还支持批量处理,可以一次性对多个预分词单元进行模型调用,进一步减少了函数调用的开销。

原生多线程支持

在 v0.23 中,如果多个线程同时使用同一个分词器实例,它们会被一个全局锁串行化,导致严重的性能瓶颈。v1 通过精心设计的数据结构解决了这个问题。现在,一个共享的分词器可以被多个线程同时用于编码。每个线程会从自己的子池中获取独立的暂存缓冲区和词缓存,从而实现了真正的并行处理,几乎消除了线程间的竞争。

实测性能:3到30倍的飞跃

在 Apple M4 Max 芯片上的实测数据显示,tokenizers v1 相比 v0.23,在单线程下对十种主流模型家族的编码速度提升了 3 到 30 倍。其中,GPT-2 模型的提速效果最为惊人,达到了30倍;而 T5-base 模型的提升相对较小,约为3倍。

更重要的是其扩展性。在8个物理核心上,v1 能够达到 76% 的线性扩展效率。这意味着增加核心数能带来几乎成比例的性能提升,对于高并发的服务场景至关重要。

所有这些性能提升都是在保证输出结果绝对一致的前提下实现的。通过 FNV-1a 哈希校验,可以确认 v1 产生的 token ID 序列与旧版本完全相同。

如何试用 v1 预发布版

目前,v1 的预发布版本已经可以在 crates.io 上获取。对于 Rust 用户,只需一条命令即可安装:

cargo add tokenizers --pre

如果你的项目只需要编码功能而不需要训练,可以通过关闭默认特性来减小最终二进制文件的体积:

cargo add tokenizers --pre --no-default-features --features http

API 的使用方式与之前完全一致。你现有的代码几乎不需要任何修改就能享受到性能红利。

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()); // 输出 token ID
    Ok(())
}

对于需要处理大批量文档的场景,应使用 encode_batch 方法,这是实现多核扩展的关键。

需要注意的是,Python 绑定虽然也基于同一套 Rust 代码,但由于存在额外的调用开销,上述基准测试并未包含这部分开销。

未来路线图

当前的预发布版已经包含了工作区拆分、bitcannon、词缓存、更快的查找与合并结构、可重用的模型内存、批量模型调用、更快的解码等多项已完成的工作。

在正式发布 1.0.0 之前,团队还计划完成一些关键任务,包括统一训练和推理的编码实现以确保结果一致性、按需计算偏移量和掩码、简化 Python 绑定等。

展望 1.0.0 之后,一个令人兴奋的方向是 tok-devices:探索在 GPU 上直接进行编码和批量解码的可能性。其构想是将词汇表上传到 GPU,然后利用 GPU 的并行能力计算输出位置并收集对应的字节。这将为处理超大批次的场景提供新的性能选项,当然,这还需要进一步的原型验证和性能测量。