长亭百川云 - 文章详情

【Rust 研学】LLM 入门之旅番外篇 1.3 (上):OpenAI 工程师 Andrej 权威解读 GPT 分词器

觉学社

79

2024-07-13

「 Rust 与 LLM」 是本合集的主题系列之一,本文为番外第三篇。阅读本系列文章不需要有数学基础

本合集将优先完成该主题系列文章,所以其他主题的文章优先级将降低。

「 Rust 与 LLM」主题系列将专注于自然语言处理、 Transfomer 架构和大模型相关内容,依托 Rust 开源生态和 HuggingFace 的相关 Rust 库,探秘从模型训练到模型部署、模型量化与 WebAssembly 轻量化部署的技术原理。

本文简介

OpenAI 工程师(前)Andrej Karpathy 视频介绍 GPT 分词器[1],以及 BPE 算法实现最小化分词器,用 70 行 Python 代码实现。

本文涉及内容为视频内容前 1 小时 25 分的内容。全视频大概 2 小时的内容。上篇基本上把 GPT 分词器概念和与内在逻辑讲清楚了,本文搭配视频一起学习可能更好。当然单独学习本文也是可以的。下篇继续介绍视频的后半部分,以及 Andrej 讲解 Google 开源大模型 Gemma 分词的内容。Andrej 是大模型顶级梯队 OpenAI 的一员,让我们尽可能地跟上他的节奏来学习。

Andrej 讲的太细了,比如正则、字符集和编码等都讲了,本来这些都是很多程序员都知道的基础知识。可能他想面向更广阔的人群吧。

和番外篇 1.1 一样,本文也不是视频内容的一比一文稿,是我学习这个视频的笔记,其过程中也补充了一些 Andrej 未讲的一些细节。本文的重点是对 《LLM 入门之旅》第一篇分词器内容的补充,以及与下一篇正文 「BPE 分词模型 Rust 实现」的衔接。

分词器概要

Andrej Karpathy 坦言分词器是他最不喜欢的大模型的一部分,但是分词器的重要性让他不得不专门讲一些重点。

大语言模型的很多奇怪之处都可以追溯到分词器。

GPT 最初的分词器是基于 莎士比亚数据集[2]来训练的。Andrej 在视频里介绍了的 Google Colab 的这份学习文件,在这里:colab-gpt-dev-ipynb[3]。

Andrej Karpathy 讲授的关于从头开始用代码构建神经网络的课程(Python实现):Neural Networks: Zero to Hero[4],视频合集[5]

然后引出了 GPT-2 的论文:《语言模型是无监督多任务学习者》,相关源码openai/gpt-2[6] 。通过论文来说明分词器的重要性。

然后也提到了,MetaAI 的论文 Llama 2: Open Foundation and Fine-Tuned Chat Models[7] ,文章训练并开源了模型 Llama2 系列模型。论文中提到 Llama2 团队在基于 2 万亿(trillion)个 token 的数据上进行训练。

llama2 论文对Llama2做了大量的安全和有用性的微调,并进行了大量的数值试验,实验证明,Llama2-chat比其它被比较的开源的chat模型(BLOOM,LLaMa1,Falcon)效果好,且有潜力成为一些未开源chat模型(ChatGPT,BARD)的替代。

Tokenization 的复杂性

分词是现有技术中相对复杂和棘手的组成部分,但有必要详细了解,因为很多与神经网络相关的或者看似神秘的 LLMs 的缺点实际上可以追溯到分词。

比如下面的一些问题:

  • 为什么LLM不能拼写单词?分词。

  • 为什么LLM不能执行超级简单的字符串处理任务,比如反转字符串?分词。

  • 为什么LLM在非英语语言(如日语)方面表现更差?分词。

  • 为什么LLM在简单算术上表现不佳?分词。

  • 为什么GPT-2在编写 Python 代码时遇到了比必要更多的麻烦?分词。

Python 代码著名的空格缩进,在分词器处理时没有处理好。到 GPT-4 才得以改进,比如把 四个空格中的前三个看作一个 token,预留一个空格为另一个 token,如果是八个空格的缩进,则把前七个空格看作一个 token。

  • 为什么我的LLM在看到字符串“<|endoftext|>”时突然停止? 分词。

  • 关于“尾空格”的奇怪警告是什么?分词。

  • 为什么当我询问“SolidGoldMagikarp” 时,LLM会中断?分词。

  • 为什么我应该更喜欢在 LLMs 中使用 YAML 而不是 JSON ?分词。

  • 为什么LLM实际上不是端到端的语言建模?分词。

端到端的模型理想情况下应该是高度可解释和可控的,但LLM由于其庞大的参数量和复杂的内部结构,使得对其预测结果的解释和控制相对困难。这限制了在某些应用场景下的端到端使用。

  • 什么是苦难的真正根源?分词

补充:后端API在进入标记器之前会对<|和|>中的所有内容进行清理,GPT是自回归的令牌列表补全机器,它们本质上是"流"。API具有更多的数据清洗和一些无法禁用的隐式停止序列。参考地址[8] 。这个问题应该没被修复。

补充:GPT的嵌入空间的中心包含一些不寻常的标记,包括字符串“SolidGoldMagikarp”。如果在查询中插入这些标记,GPT会显示异常行为;例如,它将“SolidGoldMagikarp”视为单词“distribute”。来源ChatGPT goes wild! Is “SolidGoldMagikarp” the new “42”?[9] ,这个问题应该已经被修复了。另外一个异常 token 是 "externalToEVA",我没试过。

补充:使用 Yaml 替代 Json 格式与大模型交互更具成本效益和时间效益

这背后的原因大概是什么呢? 和分词有关。

OpenAI 的在线分词工具展示

OpenAI Tokenizer Online 工具[10] 能可视化分词结果。

尝试下面示例:

`{      "name": "Alex",      "age": 19,      "brothers": [         {            "name": "Tony",            "age": 16         },         {            "name": "Herry",            "age": 20         }    ]   }   `

上图中,同一种颜色代表被分词为一个 token,不同的颜色代表不同的 token

不同的大模型采用的分词算法具体可能不太一样,但思路是一致的。GPT 是采用了 BPE 分词算法。

当文本里出现像花括号这种界定符时,分词就不太一样了。上面每个花括号(和方括号)都有独立的 token 。在 JSON 格式中需要太多的界定符。这个 JSON 内容分词后 Token 总数为 61 。

再换成 YAML:

`---   name: Alex   age: 19   brothers:   - name: Tony     age: 16   - name: Herry     age: 20   `

YAML 格式 token 总数为 35,几乎少一半。

Json 文件里很多花括号、引号、方括号等界定符,而 Yaml 格式相对来说则没有太多的花括号界定符,从而能帮助语言模型更快、更便宜地生成与 Json 格式输入完全相同的内容。

除了速度和成本方面的优势外,YAML相对于JSON还有一个重要的优点——能够包含注释。这意味着 Yaml 中可以通过注释添加 Prompt ,比如 CoT。

题外话:这样看来,Toml 格式也许同样比 JSON 强,可能比 Yaml 更精准,但 token 数不一定少。

分词可视化还可以尝试下面示例:

`Tokenization is at the heart of much weirdness of LLMs.Do not brush it off.      127 + 677 = 804   1275 + 6773 = 8041      Egg.   I hava an Egg.   egg.   EGG.      分词器用在人工智能自然语言处理中,它是大语言模型奇怪行为的根源。   `

这展示了英文、算术、中文、代码的分词形式。

拿 Python 代码来说,之前 GPT-2 没有对 Python 缩进合理分词,导致解读和生成 Python 代码碰到很多麻烦,但是 GPT-4 对缩进做了改进,比如四个空格,将前三个空格分为一个token,第四个空格和紧挨的代码单词分为一个 token。

另外值得注意的是,token 数,与使用 OpenAI 大模型的价格也有关系。因为商用大模型的收费目前是按 token 数来计算的。比如上图的右上角就能看到你输入的提示词被分词以后 token 数的价格。

如果你自己实际操作并观察这些 token id 之后,你会发现,token id 的数字越小,它对应的字符就越常见。比如上图中 token id 为 284 的就对应 •=• ,等号较常见。分词器 Token ID 实际上是一种索引。

在诸如词嵌入(Word Embeddings)或 Transformer 模型中,token id 被用来检索词向量或执行编码操作。例如,在 BERT、GPT 等模型中,输入文本首先被分词器转换为 token id 序列,然后这些 id 用于从嵌入层(Embedding Layer)检索相应的词向量。这些词向量随后被用于模型的后续处理阶段,如特征提取、语义分析等。

说明:关于词嵌入和 Transfomer 在本主题文章后续会逐章节介绍。

字符编码对分词的影响

Andrej 在视频中对比了 UTF-8 编码和 UTF-16 编码的文本分词后的结果,以此来说明 UTF-8 编码为什么更好。 这里就不展示太多。作为程序员,都应该了解 UTF-8 的好处。简言之,非 UTF-8 编码的文本,比如 UTF-16 分词后多了很多 0,这属于浪费。而 UTF-8 编码文本分词后的 Token ID 集合没有 0,非常紧凑。

然而,对于后续到 Transfomer 架构进一步处理时,还不能直接使用 UTF-8 编码的原始文本字节,因为这样产生的 Token 序列会非常长,导致 Transfomer 很难处理 token 相关上下文而进行预测

为了解决这个问题,就需要对 Token 序列进行压缩。所以,BPE 算法就登场了

BPE 算法介绍

1994年,菲利普·盖奇(Philip Gage)引入了一种新的数据压缩技术,它用一个在数据中不出现的字节替换常见的连续字节对。换句话说,通过将单词分解为部分,我们可以用唯一的 Token ID表示单词,并且仍然可以高效地存储和检索它们。这种技术被称为字节对编码(BPE),并被用作子词标记化。这种技术已成为BERT、GPT模型、RoBERTa等模型的基础。

在前一篇 《LLM 入门之旅番外篇数据压缩入门》中,已经对 BPE 算法做了一个简单介绍,并使用了一个示例来说明。 说明:这里我重新换用 Wiki [11] 的示例来进一步介绍这个算法逻辑。

原始算法通过迭代地用未使用的“占位符”字节替换目标文本中最常见的连续字符序列来操作。当找不到序列时,迭代结束,目标文本被有效地压缩。解压缩可以通过反转这个过程来进行,通过查询已知的占位符术语与其对应的表示序列,使用查找表来完成。在原始论文中,这个查找表被编码并与压缩的文本一起存储。

假设要编码的数据是:aaabdaaabac

字节对"aa"出现最频繁,因此将其替换为数据中未使用的字节,例如"Z"。现在有以下数据和替换表:

`ZabdZabac   Z=aa   `

现在频繁出现的字节对是"ab",使用"ab"重复该过程,将其替换为"Y":

`ZYdZYac   Y=ab   Z=aa   `

唯一剩下的字节对只出现一次,编码可能在这里停止。或者,该过程可以继续进行递归字节对编码,将"ZY"替换为"X"。

`XdXac   X=ZY   Y=ab   Z=aa   `

这些数据无法通过字节对编码进一步压缩,因为没有出现超过一次的字节对。要解压数据,只需按相反的顺序进行替换。

BPE 算法极简实现(Python 代码解释)

Andrej 在视频中使用 Tokenization.ipynb[12] ,一步步教你如何实现 BPE 。

首先,需要对文本进行处理:

`# 文本来自于 https://www.reedbeta.com/blog/programmers-intro-to-unicode/   # 此处省略大部分文本内容   text = "Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺‌🇳‌🇮‌🇨‌🇴‌🇩‌🇪! 😄 ..."   # 转换为 UTF-8 编码的字节序列   tokens = text.encode("utf-8") # raw bytes   # 转换 UTF-8 编码下的每一个字节为整数表示,范围是 0..255    tokens = list(map(int, tokens))    # 打印   print('---')   print(text)   print("length:", len(text))   print('---')   print(tokens)   print("length:", len(tokens))   `

输出(部分省略):

`---    Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺‌🇳‌🇮‌🇨‌🇴‌🇩‌🇪! 😄 ...   length: 533    ---    [239, 188, 181, 239, 189, 142, 239, 189, 137, 239, 189, 131, 239, 189, 143, ......]    length: 616   `

看得出来,原文本字符串的长度是 533,而转换后的字节序列长度是 616,这是因为 UTF-8 编码有些非英文字符会占三个字节,转换后按字节数算长度,而原文本字符串是按字符来算长度的。

实现一个 get_stats 方法,传入 ids为上面生成的数字数组:

`def get_stats(ids):       counts = {}       for pair in zip(ids, ids[1:]):            counts[pair] = counts.get(pair, 0) + 1       return counts      stats = get_stats(tokens)   print(stats)   print(sorted(((v,k) for k,v in stats.items()), reverse=True))   `

该函数接收一个整数列表 ids 作为输入,并计算列表中连续整数对的出现次数。然后,它在 tokens 列表上调用这个函数,并将结果存储在 stats 变量中。最后,它打印出 stats 字典的内容,并按出现次数从多到少排序并打印每个整数对及其出现次数。

输出(部分省略):

`{(239, 188): 1, (188, 181): 1, (181, 239): 1, (239, 189): 6, (189, 142): 1, (142, 239): 1, (189, 137): 1, (137, 239): 1, (189, 131): 1, ...}   [(20, (101, 32)), (15, (240, 159)), (12, (226, 128)), (12, (105, 110)), (10, (115, 32)), (10, (97, 110)), (10, (32, 97)), (9, (32, 116)), ...]   `

执行下面代码从结果集中寻找次数最多的一对:

`top_pair = max(stats, key=stats.get)   top_pair # (101, 32),排序第一的一组数字对   `

接下来,我们把这些数字对用新的数字来进行替换:

`def merge(ids, pair, idx):     # in the list of ints (ids), replace all consecutive occurences of pair with the new token idx     newids = []     i = 0     while i < len(ids):       # if we are not at the very last position AND the pair matches, replace it       if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:         newids.append(idx)         i += 2       else:         newids.append(ids[i])         i += 1     return newids      # 用例:用 99 替换 列表中 (6,7)数字对   # 输出:[5, 6, 99, 9, 1]   print(merge([5, 6, 6, 7, 9, 1], (6, 7), 99))   # 用 256 替换上面的 tokens 中 top_pair :(101, 32)   tokens2 = merge(tokens, top_pair, 256)   print(tokens2)   print("length:", len(tokens2))   `

该函数接收一个整数列表 ids、一个整数对 pair 和一个整数 idx 作为输入。该函数遍历整数列表 ids,找到所有连续出现的整数对 pair,并将它们替换为新的整数 idx。然后,在 tokens 列表上调用这个函数,并将结果存储在 tokens2 变量中。最后,它打印出 tokens2 列表的长度。

输出(省略部分):

`[239, 188, 181, ... 104, 256, 118, ...]    length: 596   `

可以看到 256 已经把 (101,32) 替换了。这个过程也被称为合并(merge)。

现在将 Unicode Wiki 中全部文本输入。

`def get_stats(ids):       counts = {}       for pair in zip(ids, ids[1:]):           counts[pair] = counts.get(pair, 0) + 1       return counts      def merge(ids, pair, idx):     newids = []     i = 0     while i < len(ids):       # 判断边界       if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:         newids.append(idx)         i += 2       else:         newids.append(ids[i])         i += 1     return newids      # ---   vocab_size = 276 # 最终字典集的大小   # 不能用 0-255 内的数字作为替代数字,所以需要后 20 个数字   num_merges = vocab_size - 256    ids = list(tokens) # copy so we don't destroy the original list      merges = {} # (int, int) -> int   # 在迭代中merge   for i in range(num_merges):     stats = get_stats(ids)     pair = max(stats, key=stats.get)     idx = 256 + i     print(f"merging {pair} into a new token {idx}")     ids = merge(ids, pair, idx)     merges[pair] = idx      print("tokens length:", len(tokens))   print("ids length:", len(ids))   print(f"compression ratio: {len(tokens) / len(ids):.2f}X")   `

输出:

`merging (101, 32) into a new token 256   merging (240, 159) into a new token 257   merging (226, 128) into a new token 258   merging (105, 110) into a new token 259   merging (115, 32) into a new token 260   merging (97, 110) into a new token 261   merging (116, 104) into a new token 262   merging (257, 133) into a new token 263   merging (257, 135) into a new token 264   merging (97, 114) into a new token 265   merging (239, 189) into a new token 266   merging (258, 140) into a new token 267   merging (267, 264) into a new token 268   merging (101, 114) into a new token 269   merging (111, 114) into a new token 270   merging (116, 32) into a new token 271   merging (259, 103) into a new token 272   merging (115, 116) into a new token 273   merging (261, 100) into a new token 274   merging (32, 262) into a new token 275      tokens length: 24597   ids length: 19438   compression ratio: 1.27X   `

看得出来, 这 20 个次数最多的连续整数对被替换为了相应的数字,同时这也是一个字典集,将来解码时候也会用到。

原始 tokens 长度是 24597 ,而 BPE 压缩以后长度为 19438,压缩率高达 1.27 倍,还是可以的。

在大模型架构中,分词器实际上属于独立的一层

因为大模型不能直接识别文本字符,所以分词器在这里可以看作是充当了一个「转译层」,即,将输入文本转译为大模型能识别的词元集

所以,要给大模型使用,分词器还需要提供两个 API : encodedecode

先实现解码 decode

`vocab = {idx: bytes([idx]) for idx in range(256)}      print(vocab)      for (p0, p1), idx in merges.items():       vocab[idx] = vocab[p0] + vocab[p1]      # 定义 decode 函数   def decode(ids):     # given ids (list of integers), return Python string     tokens = b"".join(vocab[idx] for idx in ids)     # 将 tokens 字节对象解码为 Python 字符串     text = tokens.decode("utf-8", errors="strict")     return text      print(decode([89])) # 输出 Y,合法字节   # print(decode([245])) # 非法字节,输出乱码,使用该函数要注意 UTF-8 编码   # print(decode([128]))   `

这段代码首先定义了一个名为 vocab 的字典,该字典将整数索引映射到它们对应的单个字节的字节对象。然后,它遍历一个名为 merges 的字典,该字典将整数对映射到新的整数索引。对于每个这样的映射,它在 vocab 字典中添加一个新的条目,该条目将新的整数索引映射到由两个原始字节组成的字节对象。最后,它定义了一个名为 decode 的函数,该函数接收一个整数列表 ids 作为输入,并返回一个 Python 字符串,该字符串由 ids 中的整数索引对应的字节对象解码而成。

输出(省略部分):

`{0: b'\x00', 1: b'\x01', 2: b'\x02', 3: b'\x03', ...}   Y   `

然后再实现 encode

`def encode(text):     # given a string, return list of integers (the tokens)     tokens = list(text.encode("utf-8"))     while len(tokens) >= 2:       stats = get_stats(tokens)       pair = min(stats, key=lambda p: merges.get(p, float("inf")))       if pair not in merges:         break # nothing else can be merged       idx = merges[pair]       tokens = merge(tokens, pair, idx)     return tokens      print(encode(""))   print(decode(encode("hello world"))) # hello world   `

encode 就是把前面 bpe 的几个函数整合起来。

分词预处理

此时的 BPE 算法还比较简陋,实际操作中还有很大问题。

Andrej 在视频中给出了  语言模型是无监督多任务学习者 (Language Models are Unsupervised Multitask Learners)[13] ,是 2019 年发布的关于 GPT-2 的论文。

该论文里提到:

字节对编码(BPE)(Sennrich等,2015)是字符级和词级语言建模之间的实用中间地带,它有效地在常见符号序列的词级输入和不常见符号序列的字符级输入之间进行插值。尽管其名称为字节对编码,但参考BPE实现通常操作的是Unicode代码点而不是字节序列。

为了对所有Unicode字符串进行建模,这些实现需要包含完整的Unicode符号空间。这将导致一个基本词汇量超过130,000,而没有添加任何多符号标记。与通常使用BPE的32,000到64,000个标记词汇相比,这是一个过大的数量。

相比之下,字节级版本的BPE只需要一个大小为256的基本词汇。然而,直接将BPE应用于字节序列会导致次优的合并,因为BPE使用贪婪的基于频率的启发式方法来构建标记词汇。

我们观察到 BPE 包括许多常见单词的多个版本,比如dog,因为它们以多种变体出现,比如dog. dog! dog?。这导致了有限词汇槽和模型容量的次优分配。 为了避免这种情况,我们阻止BPE在任何字节序列中跨字符类别进行合并。我们对空格添加了一个例外,这显著提高了压缩效率,同时只对多个词汇标记之间的单词造成了最小的碎片化。

主要问题在于,现在这种 BPE 算法无法将 dog. dog! dog? 这三个合并为同一个 token,而是三个。而我们期望它们分为同一个 token。

所以他们想了一个办法,具体来说,就是使用正则表达式匹配,阻止跨字符类别进行合并

下面展示了 gpt2[14] 这个仓库的 encoder.py 关键代码:

`mport regex as re      # 符合这个正则规则的文本,永远不会被合并   gpt2pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")      print(re.findall(gpt2pat, "Hello've world123 how's are you!!!?"))      example = """   for i in range(1, 101):       if i % 3 == 0 and i % 5 == 0:           print("FizzBuzz")       elif i % 3 == 0:           print("Fizz")       elif i % 5 == 0:           print("Buzz")       else:           print(i)   """   print(re.findall(gpt2pat, example))   `

输出:

`['Hello', "'ve", ' world', '123', ' how', "'s", ' are', ' you', '!!!?']   ['\n', 'for', ' i', ' in', ' range', '(', '1', ',', ' 101', '):', '\n   ', ' if', ' i', ' %', ' 3', ' ==', ' 0', ' and', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n       ', ' print', '("', 'FizzBuzz', '")', '\n   ', ' elif', ' i', ' %', ' 3', ' ==', ' 0', ':', '\n       ', ' print', '("', 'Fizz', '")', '\n   ', ' elif', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n       ', ' print', '("', 'Buzz', '")', '\n   ', ' else', ':', '\n       ', ' print', '(', 'i', ')', '\n']   `

意思就是说,先对需要分词的文本做一个规范化和预处理。利用正则表达式将不需要分词的特殊符号匹配出来,只剩下有意义的文本,这样相同的文本就更容易被合并为同一个 token 了。

Andrej 视频里还讲了正则表达式规则的意义,本文就忽略了,因为看本文的应该都懂这个。Andrej 讲的太细,他应该是想面向更广的人群来普及大模型分词器原理。

实际上 gpt2[15] 这个仓库还有一个最小化的 Transfomer 架构实现,这里就不多做介绍,后续文章再说。

OpenAI tiktoken Rust 实现

OpenAI 开源了 Rust + Python 实现的 tiktoken[16] 库,也使用了 BPE 分词。它号称比 HuggingFace 的分词器快 3到6倍。

看了下性能测试代码,这个性能数据可能只在特定情况下更快

tiktoken 支持 GPT-4 分词,核心是 Rust 实现的,在 Rust 之上提供了 Python 绑定(基于 pyo3)。

`import tiktoken      # GPT-2 (未合并空格)   enc = tiktoken.get_encoding("gpt2")   print(enc.encode(" hello world!!!"))      # GPT-4 (合并空格)   enc = tiktoken.get_encoding("cl100k_base")   print(enc.encode(" hello world!!!"))   `

输出:

`[220, 220, 220, 23748, 995, 10185]    [262, 24748, 1917, 12340]   `

GPT-4 分词的 API 实现:

`def cl100k_base():       mergeable_ranks = load_tiktoken_bpe(           "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken",           expected_hash="223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7",       )       special_tokens = {           ENDOFTEXT: 100257,           FIM_PREFIX: 100258,           FIM_MIDDLE: 100259,           FIM_SUFFIX: 100260,           ENDOFPROMPT: 100276,       }       return {           "name": "cl100k_base",           "pat_str": r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+""",           "mergeable_ranks": mergeable_ranks,           "special_tokens": special_tokens,       }      `

这里和 GPT-2 的区别是,多了 special_tokens (用于指定特殊的 token,不参与分词合并)。同样,它也用了正则表达式 pat_str,原理是类似的。

增补:

其核心 BPE 的 Rust 实现,代码优化点在于使用了 FxHashMap 来提升编码器和解码器的哈希映射(字节序列映射到其对应的Rank,即令牌索引的哈希映射)的性能。

在 BPE 算法中,需要频繁使用正则表达式来识别和处理文本中的令牌。当处理的文本量大时,这些操作会占用大量的 CPU 时间,算是计算密集型。所以代码里还使用了多线程并发,提升了并行度且减少了等待。以及使用线程本地存储(TLS)来缓存正则表达式副本进一步避免多线程并发访问锁的开销。

P.S. 代码注释中提到尝试使用rayon库(Rust的数据并行处理库)来进一步提升性能,但最终没有采用。

延伸阅读

一些有趣的文章推荐:

  • SolidGoldMagikarp (plus, prompt generation)[17]

  • Anomalous tokens reveal the original identities of Instruct models[18]

  • Ignore This Title and HackAPrompt: Exposing Systemic Vulnerabilities of LLMs through a Global Scale Prompt Hacking Competition[19]

  • YAML vs. JSON: Which Is More Efficient for Language Models?[20]

  • GPT Tokens Explained - what they are and how they work[21]

  • https://github.com/openai/tiktoken

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2