尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

硬件友好的Tokenisation:在普通CPU上把分词器性能跑满

硬件友好的Tokenisation:在普通CPU上把分词器性能跑满 在大模型推理的性能优化清单里tokenisation 通常排在模型前向和显存之后但它实际上是最容易被忽略的一环。Gigatoken 这个方向把 tokenisation 和 hardware 放在一起讨论核心不是发明新的分词算法而是让分词器的数据结构和执行方式对 CPU 友好缓存命中率更高、内存分配更少、分支预测更好、并行扩展更稳定。对正在做离线语料清洗、推理前置处理或 tokenizer 压测的开发者来说真正要解决的问题不是“单词表有多大”而是“给定一个巨大的文本文件如何在有限 CPU 资源上把它稳定地转成 token id 序列”。下面的内容会从硬件视角重新理解 tokenisation然后设计一个可在普通 Linux 机器上验证的小例子最后给出性能指标、常见坑和落地建议。1. 先理解 tokenisation 为什么会在真实项目里慢下来1.1 分词器到底在做什么tokenisation 的核心工作很简单把一段人类可读的文本转换成模型能处理的 token id 数组。常见流程可以拆成四步原始文本 - UTF-8 字节流 - 预分词 - 子词合并/BPE 匹配 - token id 数组第一步把字符串编码成字节。第二步把文本切分成词或更小的单元。第三步根据词表做最长匹配或合并。第四步输出整数数组。从算法上看这个过程通常是 O(n) 的每个字符只会处理常数次。但在真实项目里tokenisation 的性能并没有想象中好原因在于大多数实现把精力放在了“让代码好写”上而不是“让 CPU 好跑”上。一个 Python 字典查找、一次字符串切片、一次小对象分配在单个字符级别看起来都不贵一旦面对千万级 token成本就会被放大成秒级甚至分钟级差距。1.2 性能账单里被忽略的那一部分很多人不重视 tokenisation是因为它在大模型单次推理请求中占比不高。模型生成一个 token 可能需要几十毫秒而 prompt 分词通常只要几毫秒。但在三种场景下tokenisation 会变成真正的瓶颈。第一种是离线数据预处理。要在 TB 级语料上做 tokenization、清洗、去重分词吞吐会直接决定数据管道跑完一轮需要多少小时。第二种是长文档场景。几千字甚至几万字的长文本单次分词耗时可能会从几毫秒涨到几十毫秒在并发请求下会被放大。第三种是高频小请求场景。请求本身很短模型反而没生成几个 tokentokeniser 的固定开销就会占很大比例这时候就不能再说“它占比很小”了。于是问题变成了能否让 tokenisation 在已有硬件上跑得更快而不是等着买更贵的机器。1.3 “让分词关心硬件”意味着什么算法复杂度解决的是“计算量”问题硬件友好解决的是“计算效率”问题。同样一条 BPE 合并流程用哈希字典和字符串切片实现和用连续数组、整数 ID、查表法实现在 CPU 上的表现可能差一个数量级。“关心硬件”至少包含四层内容内存访问要尽量连续避免随机指针跳转。热点数据结构要尽量紧凑保证缓存命中。条件分支要尽量可预测避免大量分支预测失败。多次调用的内存要尽量复用避免频繁分配和释放。一个很务实的目标是在没有专用分词硬件和额外硬件 license 的通用 CPU 环境里把 tokenisation 从“能跑”变成“跑满”。这不需要特殊设备需要的只是把实现方式从“给人看”改成“给 CPU 看”。2. 从算法优化转向内存布局优化硬件视角下的分词问题2.1 CPU 眼中的文本不是字符串而是字节流在 Python 这类高级语言里文本被抽象成 Unicode 字符串。每个字符是一个独立的逻辑对象底层可能使用变长编码存储。CPU 本身不认识“字符”它只认识连续的字节。例如中文“你好”在 UTF-8 编码下是 6 个字节s 你好 b s.encode(utf-8) print(b) # b\xe4\xbd\xa0\xe5\xa5\xbd如果 tokeniser 频繁做字符级切片、合并、比较高级语言的字符串对象会带来大量间接访问。尤其是 Python 字符串的切片会创建新对象循环一多内存分配压力立刻上来。更合适的方式是尽量直接在bytes或bytearray上操作。字节级 BPE 之所以在很多模型里流行一个重要原因就是它绕过了字符语义直接在 UTF-8 字节流上学习合并规则这样 CPU 只需要处理连续的字节数组不需要理解 Unicode 字符边界。2.2 缓存和内存带宽对分词的影响现代 CPU 和内存之间存在着明显的速度差距。CPU 访问 L1 缓存大约是几个周期访问 L2 缓存大约十几个周期访问主内存可能需要几十上百个周期。所有优化都要先考虑热点数据是否放在缓存里。tokenisation 最常见的不友好写法是把一个 5 万词的 vocabulary 存在 Python dict 或 C 的 unordered_map 里每个 key 是std::string每个 value 是 token id。查找一个 token 时先对字符串做哈希再通过哈希桶访问一个链表节点接着比较字符串内容。这个过程会跳转多个内存地址而且每次比较都可能读取不连续的字符串数据。如果改成数组、开放寻址哈希表、紧凑 trie 或按照 token id 排序的连续内存块单个查找只需要很少的随机访问。更重要的是顺序扫描文本字节时CPU 的硬件预取器可以提前把后续数据加载到缓存而随机访问哈希表时预取器基本失效。大文件读取也一样。一次性read()整个大文件会产生一次大内存拷贝使用mmap或分块读取可以把文件内容映射到进程地址空间按需加载减少内存带宽浪费。2.3 分支预测和整数运算tokenisation 代码里有很多判断当前字符是不是空格当前 token 是否在词表里当前字节是否是某个 UTF-8 前导字节。这些判断最终都会变成 CPU 分支指令。CPU 会预测分支走向。如果文本内容非常随机例如一个字符是空格的概率忽高忽低分支预测可能频繁失败。分支预测失败的代价通常是十几个甚至几十个周期虽然单次不大但在按字节遍历的大循环里会被反复放大。硬件友好的做法是减少不可预测分支。比如用查表法替代字符分类判断def build_byte_class_table() - bytearray: table bytearray(256) for b in b \t\r\n: table[b] 0x02 # 空白 for b in b.,;:!?()[]{}: table[b] 0x01 # 标点 return table BYTE_CLASS build_byte_class_table()这样判断一个字节是否空白不需要连续if只需要一次数组读取然后根据类别决定行为。数组只有 256 字节几乎一定在 L1 缓存里。2.4 并行化为什么不是简单加线程多线程看起来是提高分词吞吐的最直接手段但真正做起来时很多项目会在三个地方出问题。第一个是边界问题。如果两个线程分别处理同一个文件的两段中间一个 token 可能被切断。比如一个连续的子词单元正好跨在块边界上两边各看到半个 token结果就和单线程不一致。解决思路是按安全边界切块或者预留重叠区域最后再处理边界 token。第二个是共享状态问题。如果词表在分词过程中需要动态更新或者某个计数器是共享变量多线程访问就可能出现竞态。生产上更稳妥的做法是词表只读每个线程有自己的输出缓冲最后按顺序合并。第三个是伪共享问题。多个线程各自维护一个整数或短数组如果它们恰好落在同一个缓存行里其中一个线程写入会让整个缓存行失效其他线程被迫重新加载。这种性能损耗在 perf 里很难一眼看出但会把多线程加速比拖下来。正确的做法是让每个线程的数据结构按缓存行对齐或者每个线程持有独立的较大缓冲区。3. 设计一个硬件友好的小型分词器从思路到代码3.1 目标在通用 CPU 上把预分词做得又快又稳这里不训练完整 BPE 词表而是聚焦 tokenisation 里最经常被先遇到的一层预分词。预分词负责把文本切成候选词块它是后续子词合并的输入。如果预分词本身写得很慢后面任何优化都很难弥补。设计目标有三个输入是字节流不转成 Unicode 字符串。热点循环使用查表法不依赖复杂字典。输出是整数 ID 缓冲不产生大量小对象。下面代码用来演示思路。实际项目换成 C、Rust 或 Cython 时核心结构是一样的。3.2 用字节数组和整数 ID 取代字符串切片先从读取开始。简单场景可以直接把文件读成 bytesfrom pathlib import Path data Path(corpus.txt).read_bytes()数据量大时read_bytes()会一次性把整个文件复制到进程内存内存压力比较大。生产环境可以改用mmap或分块读取让操作系统按页加载文件内容避免一次性复制。接着定义字节类别表。这里用 0 表示普通字符1 表示标点2 表示空白def build_byte_class_table() - bytearray: table bytearray(256) for b in b \t\r\n: table[b] 0x02 for b in b.,;:!?()[]{}: table[b] 0x01 return table BYTE_CLASS build_byte_class_table()用这张表扫描文本时不需要反复调用isspace或isalnum每次检查只是读一次数组。3.3 用查表取代散列字典查找下面这段 Python 代码把字节流切分成词块并直接产出字节切片。注意它没有把所有字符先拆成列表也没有对每个字符做字符串判断。def scan_words(data: bytes): start -1 for i, b in enumerate(data): cls BYTE_CLASS[b] if cls 0x00: if start 0: start i else: if start 0: yield data[start:i] start -1 if start 0: yield data[start:]这段代码只做两个关键操作读BYTE_CLASS[b]以及更新start。在 C 或 Rust 版本里这个循环还可以进一步变成指针遍历static const uint8_t byte_class[256] { [ ] 2, [\t] 2, [\r] 2, [\n] 2, [.] 1, [,] 1, [;] 1, [:] 1, [!] 1, [?] 1, [(] 1, [)] 1, }; size_t scan_words(const uint8_t *text, size_t len) { size_t start SIZE_MAX; size_t count 0; for (size_t i 0; i len; i) { if (byte_class[text[i]] 0) { if (start SIZE_MAX) start i; } else { if (start ! SIZE_MAX) { // 这里拿到一个完整词块 text[start .. i-1] count; start SIZE_MAX; } } } if (start ! SIZE_MAX) count; return count; }同一个字节分类逻辑C 版本里byte_class是一个长度为 256 的静态数组每次访问都落在同一段缓存里。查找单个 token 是否在词表时也应尽量使用这种紧凑结构而不是散列指针链。3.4 按批处理避免每次调用都做分配生成 token id 时如果每个 token 都创建一个 Python 列表或字符串对象性能会非常差。更合理的方式是维护一个可复用的输出缓冲class TokenOutput: def __init__(self, capacity: int 1 20): self.ids [0] * capacity self.size 0 def reset(self): self.size 0 def append(self, token_id: int): if self.size len(self.ids): self.ids.extend([0] * (len(self.ids) // 2)) self.ids[self.size] token_id self.size 1每次处理完一段文本后调用reset()清空size而不是重新创建一个新列表。ids数组会一直复用容量不足时按比例扩容避免频繁触发内存分配。3.5 并行分块与边界处理如果要用多进程跑分词切分策略很重要。一个简单的做法是尽量在换行符附近切分避免把常见 token 切成两半from concurrent.futures import ProcessPoolExecutor BLOCK_SIZE 1 22 OVERLAP 64 def split_blocks(data: bytes): start 0 n len(data) while start n: end min(start BLOCK_SIZE, n) if end n: # 在当前块末尾附近找换行符找不到就往后多读一点 nl data.find(b\n, start BLOCK_SIZE - 1, min(end OVERLAP, n)) if nl ! -1: end nl 1 yield data[start:end] start end def process_block(block: bytes): return list(scan_words(block)) with ProcessPoolExecutor(max_workers4) as pool: for block_tokens in pool.map(process_block, split_blocks(data)): # 这里可以继续做 BPE 合并 pass这个例子只演示了分块和并行真实生产里还需要处理跨块 token 的合并。有一个原则可以参考如果预分词按空白边界切分那么在换行符附近分块基本安全如果 token 可以跨空白必须预留重叠区域并在最后把重叠部分按单线程顺序重新合并。关键参数可以单独抽成配置参数含义常见取值影响BLOCK_SIZE每个分块大小1 MiB 到 8 MiB太小则任务调度开销大太大则内存占用高OVERLAP边界重叠字节数大于最大 token 长度太小会切断 token太大会重复处理MAX_WORKERS并行任务数与 CPU 核数相关太少跑不满 CPU太多会引入调度和切换开销OUTPUT_CAPACITY输出缓冲初始容量1 MiB 左右太小会频繁扩容太大会浪费内存4. 如何验证一个分词器真正“关心了硬件”4.1 正确性必须先于性能性能优化前必须先确认新实现和旧实现输出一致。对一个正在使用的 tokenizer最稳妥的做法是准备一份 golden 数据集里面包含空文本、普通英文、中文、Emoji、超长词、连续空白、末尾换行等情况然后比较新旧实现的 token id 序列是否完全一致。如果使用字节级 BPE还要单独验证 UTF-8 边界。例如一个中文字符占 3 个字节如果切块把第 2 个字节切到上一块第 3 个字节切到下一块那么两块各自解码都会失败。必须保证切块不会把多字节字符拆散或者在边界处做补偿。4.2 用 perf 观察 cache-misses 和 branch-misses在 Linux 上perf是观察硬件行为的首选工具。编译一个 C 版本的分词器后可以直接统计关键硬件事件gcc -O2 -o tokenizer_demo tokenizer_demo.c perf stat -e task-clock,cycles,cache-references,cache-misses,branch-misses ./tokenizer_demo corpus.txt重点看两个指标cache-misses比例越高说明热点数据越是散落在内存各处越需要调整数据结构。branch-misses比例越高说明循环里的分支越不可预测越应该考虑查表法或减少条件判断。perf的输出会因为机器、编译器、输入文本不同而有很大差异所以不要只看某一台机器的绝对值要看优化前后的相对变化。4.3 用 stdin 吞吐作为外部指标硬件内部指标最终要体现到业务指标上。最简单的外部指标是“每秒处理多少 MB 文本”或“每秒处理多少 token”。time ./tokenizer_demo corpus.txt /dev/null如果机器上安装了hyperfine可以更稳定地比较多个实现hyperfine --warmup 2 ./tokenizer_naive corpus.txt ./tokenizer_hw corpus.txt记录耗时后用文件大小除以耗时得到 MB/s。再结合平均 token 长度可以换算成 tokens/s。这个指标可以直接评估当前机器能否满足离线管道或在线服务的需求。4.4 从指标反推代码改动方向观察指标后可以按下面的对应关系决定下一步优化观察指标可能原因优先优化动作cache-misses高哈希表、链表、大对象随机访问改成连续数组、开放寻址哈希表、紧凑 triebranch-misses高大量不可预测的字符分支用查表法、位运算替代 if/else单线程task-clock高tokenizer 本身没有跑满 CPU检查是否存在内存分配或随机 IO多线程加速比低锁竞争、边界重算、伪共享使用只读词表、独立输出缓冲、按缓存行对齐输出阶段占用高每次追加都分配新对象使用批量缓冲、预分配容量、返回连续数组5. 常见性能陷阱和排查路径5.1 坑一字符串反复切片和拼接典型错误写法token text[i:j] tokens.append(token)这样每个 token 都是一个新字符串对象。表面看起来代码很清晰但在大循环里会产生大量临时对象GC 压力也会上升。更不推荐的写法是在循环里用result char它每次都会创建一个新字符串。正确做法是在字节数组层面维护start和end只在需要输出时才取切片输出端尽量写入可复用的整数缓冲而不是字符串列表。5.2 坑二哈希字典随机访问导致缓存失效把整个词表放进dict或unordered_map是最容易出问题的地方。词表越大哈希桶分布越散随机访问越多。即使单个查找是 O(1)常数也可能很大。一种缓解方案是用数组存储 token ID再用紧凑 trie 做前缀匹配。trie 的子节点可以放在连续数组里访问路径会更有局部性。另一种方案是使用开放寻址哈希表把 key 和 value 都存在同一个连续数组里减少指针跳转。5.3 坑三并行分词结果不一致现象单线程结果正确开启多线程后发现同一段文本产生的 token id 序列偶尔不同。可能原因包括分块边界切坏了多字节字符或 token。多个线程共享了同一个正在修改的字典。输出合并顺序不对。排查方式很简单用同一个输入文件分别跑单线程和多线程再比对输出。如果输出不一致优先检查分块边界和共享状态。修复后把一致性测试写进 CI避免后续改动再次破坏。5.4 排查顺序和发布前检查清单遇到分词性能下降时按下面的顺序排查确认输入数据是否变化是不是文本更长、更碎片化导致统计口径变了。确认调用方式是否正确是否每次调用都重新加载词表是否传入了错误编码。确认依赖版本Python 版本、编译器优化级别、依赖库版本是否影响性能。确认配置是否生效块大小、线程数、输出缓冲容量是否和测试环境一致。确认硬件侧指标通过 perf 看 cache-misses、branch-misses、context-switches。确认是否被其他任务干扰机器上是否有别的 CPU 密集型进程。发布前建议按下面的清单检查检查项说明正确性与基线 tokenizer 在全量测试集上 ID 一致边界用例空输入、单字节、多字节字符、超长词、连续空白并发一致性多线程输出与单线程输出一致性能基线记录吞吐、CPU 占用、cache-misses 等指标内存控制峰值内存可控输出缓冲可复用参数可配置BLOCK_SIZE、MAX_WORKERS等可通过配置调整可观测性每个 batch 的耗时、token 数、异常率有日志6. 生产落地建议与扩展方向6.1 学习环境、开发环境还是生产环境取舍不同学习环境里Python 原型足够。用bytearray分类表、复用输出缓冲可以直观感受到“不创建对象”“按字节遍历”带来的差异。开发环境里要补上自动化测试和基准测试。每次改动 tokenizer 内部逻辑时都要跑一次 golden 测试确认输出没有变化同时跑一次基准脚本确认性能没有回退。生产环境里tokenizer 通常是 C、C、Rust 实现并作为独立服务或共享库被调用。这时还需要额外关注词表加载是否只在启动时做一次。在线服务是否需要限制单次分词的最大输入长度。异常输入是否会触发无限循环或超大内存分配。日志是否能记录到 P99 耗时和 token 数。Gigatoken 所代表的思路并不要求专用硬件。恰恰相反它希望在没有额外硬件 license 的普通 CPU 上也能把 tokenisation 跑到接近硬件的上限。这个目标对学习环境也成立一台普通 Linux 虚拟机足够完成实验。6.2 与现有 tokenizer 保持兼容如果当前模型已经上线换 tokenizer 是一件高风险操作。token id 改变会直接影响模型输入分布导致需要重新评估甚至重新训练。所以生产环境不建议突然替换词表。更稳妥的做法是保持原词表和输出 ID 不变只替换 tokeniser 内部实现。这样模型侧无感知风险被限制在“接口是否更快、更稳”这个范围内。替换前一定要跑 golden 测试并在灰度环境观察耗时和内存。6.3 可迁移到生产环境的硬指标实际落地时建议至少监控下面几个指标指标含义评价方式tokens/s每秒处理 token 数与压测基线对比观察回退单请求 P99 分词耗时在线服务前处理的尾部延迟应低于模型推理 P99 的十分之一左右峰值内存处理最大批次时的内存占用避免 OOM并控制 GC 次数并发波动多线程吞吐是否稳定观察加速比是否随核心数线性增长异常率非法编码、超长输入导致失败的比例应接近 06.4 从分词器扩展到硬件友好的数据处理tokenisation 只是文本处理流水线里的第一环。同样的硬件友好原则可以迁移到数据加载、文本清洗、embedding 预处理、特征工程等环节。一个比较现实的练习是拿一个 100 MB 的纯文本文件先用正则表达式分词再用上面字节查表版本分词对比两者耗时和内存。性能差异背后并不是正则表达式库本身慢而是它会创建大量中间对象、做随机访问、引入不可预测分支。把同一思路从 tokenisation 扩展到整个数据管道才是 Gigatoken 这类方向最有价值的地方。
返回列表