Skip to content

Validark/Accelerated-Zig-Parser

A high-throughput tokenizer and parser (soon™️) for the Zig programming language.

主线的 Zig tokenizer 使用确定性有限状态机。这些对于某些应用来说非常好,但词法分析通常可以采用其他技术来提高速度。

提供了两个 tokenizer 实现:

  1. 一个版本,每 64 字节的块产生几个位串,并使用这些位串跳过连续字符匹配。我曾就此主题做过两次 演讲。 (目前这段代码已经消失了,但我将在 3 个月内重新构建它,以便在 7 月的 Utah-Zig 演讲中比较 Zig Tokenizer 的主题)
  2. 一个版本,为我们在 64 字节块中所做的 所有 事情生成位串,并利用向量压缩来同时找到所有 token 的范围。参见此动画。我还做了一个关于我的宏伟计划的演讲(实际上更像是一个咆哮)这里。不幸的是,它并没有像我希望的那样实现,因为我生病了,没有时间给予它应有的关爱。但我的下一次演讲一定会让你大吃一惊,保证!

目前在我电脑上的测试台在运行时会打印出以下内容:

    Read in files in 26.479ms (1775.63 MB/s) and used 47.018545MB memory with 3504899722 lines across 3253 files
Legacy Tokenizing took       91.419ms (0.51 GB/s, 38.34B loc/s) and used 40.07934MB memory
Tokenizing with compression took  33.301ms (1.41 GB/s, 105.25B loc/s) and used 16.209284MB memory
    That's 2.75x faster and 2.47x less memory than the mainline implementation!

我还有更多的优化计划 >:D !!! 敬请期待!

请参阅我关于新 tokenizer 的文章:https://validark.dev/posts/deus-lex-machina/

Tokenizer 1:

以下内容是关于 Tokenizer 1 的。这些信息有点过时,但优化策略仍然适用。

单击此处查看我的最新工作。

Results

当前 utf8 验证器已关闭!过去几天我进行了一些性能优化,但尚未完成移植我的更改。

测试台完全读取 src/files_to_parse 文件夹中所有 Zig 文件。在我的测试中,我在 src/files_to_parse 文件夹中安装了 Zig 编译器、ZLS 和其他一些 Zig 项目。测试台迭代每个 Zig 文件的源字节(添加了 sentinels),并在每个文件上调用 tokenization 函数**,且 utf8 验证器已关闭**。

为了 tokenizing 3,218 个 Zig 文件和 1,298,380 个换行符,原始 tokenizer 和我的新 tokenizer 具有以下特征:

memory (megabytes)


raw source files | 59.162811MB

original (tokens) | 46.089775MB

this (tokens) | 18.50827MB

内存减少了 2.49 倍!

请记住,与传统 tokenizer 的速度进行比较并不一定很简单。我很容易看到通过在我的代码中进行微不足道的更改,传统 tokenizer 的性能更改约 15%。它很大程度上取决于特定的编译。也就是说,以下是我在机器上看到的一些数字(我的实现中 utf8 验证器已关闭):

x86_64 Zen 3

当前 utf8 验证器已关闭!过去几天我进行了一些性能优化,但尚未完成移植我的更改。

run-time (milliseconds) | throughput (megabytes per second) | throughput (million lines of code per second)

---|---|---

read files (baseline) | 37.03ms | 1597.85 MB/s | 35.06M loc/s

original | 218.512ms | 270.78 MB/s | 5.94M loc/s

this | 72.107ms | 820.57 MB/s | 18.01M loc/s

速度快了约 3.03 倍! 当前 utf8 验证器已关闭!过去几天我进行了一些性能优化,但尚未完成移植我的更改。

RISC-V SiFive U74

当前 utf8 验证器已关闭!过去几天我进行了一些性能优化,但尚未完成移植我的更改。

run-time (milliseconds) | throughput (megabytes per second) | throughput (million lines of code per second)

---|---|---

read files (baseline) | 318.989ms | 185.47 MB/s | 4.07M loc/s

original | 2.206s | 26.81 MB/s | 0.59M loc/s

this | 894.963ms | 66.11 MB/s | 1.45M loc/s

速度快了约 2.47 倍! 当前 utf8 验证器已关闭!过去几天我进行了一些性能优化,但尚未完成移植我的更改。

To-do

Maintenance note

奇怪的是,我认为其中一些代码也更易于维护,因为向 tokenizer 添加运算符或关键字实际上只是将另一个字符串添加到相关数组中。我在编译时断言(grep for comptime assert)中显式检查我使用的所有假设和技巧,因此违反任何这些不变量都会导致编译错误,告诉您为什么不能更改某些内容。

但是,我确实有很多奇怪的 SWAR 技巧,编译器希望有一天会自动执行。

Designing for high performance

在性能优化的微妙平衡中,您通常需要:

  1. 一次处理多个事物能力
  2. 更少无法预测的分支
  3. 线性遍历更少量的连续内存

我尝试通过以下方式实现每一个目标:

  1. SIMD,即单指令、多数据。您不必一次操作单个元素,而是可以同时操作 16、32 或 64 个元素。我们不按字符逐个遍历,而是使用 SIMD 来检查标识符/关键字的长度、引号的长度、空格的长度以及注释或单行引号的长度。这使我们能够比一次一个字节更快地移动。我们还使用 SIMD 技术来验证正确的 utf8 一致性,该技术从 simdjsontravisstaloch 移植过来,用于 simdjzon。请注意,该特定代码已获得 Apache 许可,包含在 main.zig 文件的底部。
    • 我实际上没有使用 SIMD 来查找 'a' 形式的“字符字面量”,因为这些字面量通常非常短,并且在测试中实际上并没有带来太多好处。
    • 我们不能也不想将 SIMD 用于所有内容,因为:
      • 注释可以位于引号内,引号可以位于注释内
        • 选择下一个要匹配的位串(可能?)效率不高。您必须将每个向量相乘,然后将所有向量进行 OR 运算,获取下一个位置,然后重复。我可以尝试这种方法,但我怀疑它是否实用。我还注意到,当我查看 arm64 输出时,它比 x86_64 需要 更多 向量指令,并且在 SIMD 中执行所有操作会在 arm64 上生成数百条指令。但这仍然可能值得,尤其是在 x86_64 上,但我对此表示怀疑。
      • 运算符无处不在,并且在 SIMD 中执行所有操作将需要大量工作,而标量代码执行起来并不那么糟糕。
  2. SWAR,即寄存器内的 SIMD。我们在这里将多个字节读入 4 或 8 字节寄存器,并使用传统的算术和逻辑指令来同时操作多个字节。
    • 为缺少正确 SIMD 指令的机器提供了 SWAR 回退。
      • 我们可以通过广播字符并执行异或运算来检查与字符的相等性:
 0xaabbccddeeffgghh
^ 0xcccccccccccccccc
--------------------
 0x~~~~00~~~~~~~~~~

   * 前面的步骤将在字节数组中我们在其中找到目标字节(在本例中为 `cc`)的位置产生 0。然后,我们可以添加广播的 `0x7F`。
 0x~~~~00~~~~~~~~~~
+ 0x7F7F7F7F7F7F7F7F
 ----------------
 0x8~8~7F8~8~8~8~8~

   * 这将在每个字节的最高有效位中产生一个 1 位,该字节在前一步之后最初不是 0。到目前为止,我提出的该技术的唯一问题是可能跨字节溢出。为了解决这个问题,我们在开始此算法之前屏蔽掉每个字节的最高位。这样,当我们添加 7F 时,我们知道它不会溢出到每个字节的最高有效位之外,然后我们知道我们可以查看每个字节的最高有效位来告诉我们目标字节是否 *不* 在那里。
   * 然后,我们可以屏蔽掉每个字节的最高有效位,并模拟一个 movmask 操作,即通过乘法将这些位集中在一起:
Example with 32 bit integers:
We want to concentrate the upper bits of each byte into a single nibble.
Doing the gradeschool multiplication algorithm, we can see that each 1 bit
in the bottom multiplicand shifts the upper multiplicand, and then we add all these
shifted bitstrings together. (Note `.` represents a 0)
 a.......b.......c.......d.......
* ..........1......1......1......1
-------------------------------------------------------------------------
 a.......b.......c.......d.......
 .b.......c.......d..............
 ..c.......d.....................
+ ...d............................
-------------------------------------------------------------------------
 abcd....bcd.....cd......d.......
Then we simply shift to the right by `32 - 4` (bitstring size minus the number of relevant
bits) to isolate the desired `abcd` bits in the least significant byte!

 * 即使在具有向量和强大指令的机器上,SWAR 技术仍可用于运算符匹配。

3. 通过以下方式减少不可预测的分支: * 使用 SIMD/SWAR。使用传统的 while 循环来捕获上述类别中完全无法预测数量的字符几乎可以保证每次退出循环时都会出现分支预测错误,如果分支预测器状态不佳,则可能在整个循环中出现多次分支预测错误。使用 SIMD/SWAR,我们可以改为生成一个位串,其中标记了与目标字符对应的位置中的 0,例如匹配的 ",根据光标的位置移动该位串,并计算尾随的 1(位与您可能期望的相反的原因是,当我们移动位串时,它将被 0 填充)。在大多数情况下,我们只需要一个“计算尾随的 1”操作即可找到我们应该转到的下一个位置。无需完全无法预测的 while 循环,该循环按字符逐个遍历! * 使用完美的哈希函数。具体来说,像 varconst 这样的关键字通过完美的哈希函数映射到 7 位地址空间中。可以通过将完美的哈希函数应用于每个标识符并执行表查找以查找它可能匹配的关键字,然后执行单个 16 字节与 16 字节的比较以查看标识符是否与该关键字匹配,从而检查标识符是否与关键字列表匹配。关键字在内存中被填充为 16 个字节,并在最后一个字节中存储一个 len,以便我们可以检查传入的标识符是否具有与预期关键字相同的长度。我们还使用 Phil Bagwell 的数组映射 trie 压缩技术,这意味着我们有一个 128 位位图,并使用该位图查找要检查的位置,从而使我们能够拥有一个不需要有 128 个插槽的打包缓冲区。我们对运算符执行类似的技巧。 * 由于 Zig 的编译时执行功能,我可以做的一件很酷的事情是告诉 Zig,当我们没有哈希到最大 7 位值(即 127)的运算符或关键字时,我们需要一个虚拟运算符/关键字(因为我将这些哈希到 7 位的地址空间)。如果添加或删除了哈希到 127 的运算符或关键字,编译时逻辑将自动删除或添加虚拟运算符/关键字。非常棒!目前,一种完美的哈希方案需要一个虚拟元素,而另一种则不需要。很高兴知道,如果我们进行更改,例如更改哈希函数或添加/删除运算符或关键字,它将自动找出要做的正确的事情。这些技巧在传统的编程语言中并不好。我们要么在启动时完成这项工作,要么更糟糕的是,有人将所有假设都烘焙到代码中,然后更改它就变成了 Jenga 游戏,只是更难,因为这些碎片并不都在一个地方。在 Zig 中,我们编写一次,编译时执行会处理其余的事情。 * 我使用了一个技巧,我只是为每个文件的 token 分配了上限量的内存,并使用分配器的 resize 工具来回收我没有填充的空间。这个技巧的好处是我可以始终假设有足够的空间,这消除了检查这种事情是否安全的需要。 * 我将 sentinels 放在文件的末尾(并在前面放置一个换行符)以使其余的代码更简单。这使我们可以在任何时候安全地返回一个字符,如果完美的哈希函数希望我们从只有一个字符的标识符中获取最后两个字符,并且还可以让我们安全地通过源文件的末尾。通过在缓冲区的末尾放置 "' 字符,我们可以消除在搜索这些字符的代码中的边界检查,并且只需在热循环完成后检查我们是否击中了 sentinel 节点即可。我们目前没有为换行符跳出这些循环,我们可能应该这样做。对这些的所有其他验证都应该在实际尝试分配它们应该表示的字符串或字符时进行。 * 我们无条件地做一些事情,这些事情可以隐藏在分支后面,但成本很低,因此没有意义。当成本高昂且通常可预测时,我们将其他事情隐藏在分支后面。例如,utf8 验证通常只是确保所有字节都小于 128,即 0x80。一旦我们看到一些非 ascii 序列,那么我们就必须做更多计算密集的工作,以确保字节序列有效。 * 表查找。我将 SIMD/SWAR 代码合并为一个代码,以便我们沿着完全相同的代码路径来查找要跳过的 non_newline/identifier/non_unescaped_quote/space 字符的数量。这可能比拥有 4 个单独的相同热循环副本效率更高。 * 内联 SIMD/SWAR 循环,即使在需要展开 8 次的机器上也是如此。事实证明,在我的测试中这很值得,可能是因为它是一个非常热的循环! 4. 我们通过不显式存储起始索引来减少内存消耗,这通常需要与源长度的地址空间匹配。在 Zig 的情况下,源文件被限制为最多 ~4GiB,任何给定文件只需要 32 位地址空间。因此,目标是将 32 位起始索引减少到更小的值。单调递增整数序列的准简洁方案立即浮现在脑海中,例如 Elias-Fano encoding。但是,我们可以通过简单地存储每个 token 的长度而不是起始索引来实现良好的空间压缩。因为 token 几乎总是具有可以容纳在一个字节中的长度,所以我们尝试将所有长度存储在一个字节中。如果长度太大而无法存储在一个字节中,我们改为存储一个 0,并将接下来的 4 个字节作为真实长度。这是因为 token 不能具有长度 0,否则它们将不存在,因此我们可以使用长度 0 来触发特殊行为。我们还知道,这个想法不会影响我们需要分配的 Token 元素数量的上限,因为要使 token 占据的空间是典型 token 的 3 倍,它需要具有至少 256 的长度,细心的读者可能会注意到,这明显大于 3。 5. 尽可能少地使用变量。虽然现在的机器拥有的寄存器比过去多得多,但您仍然只能访问 16 或 32 个通用寄存器!如果您有比这更多的变量,则必须溢出到堆栈(实际上比这更糟,因为表达式中的中间值也暂时需要自己的寄存器)。虽然机器确实有可以在幕后使用的额外寄存器,但您没有!因此,我们可以通过以下方式获得更好的性能 * 使用指针而不是指针 + 索引 * 巧妙地编写我们的 non_newlines 位串。我没有将我从 SIMD/SWAR 代码中获得的所有位串存储在堆栈上的 [4]u64(在 64 位机器上)中,然后单独写入 non_newlines 指针,而是将 所有 位串写入为 non_newlines 位串分配的内存中。每次,我都会将我们在分配中写入的位置增加单个位串的宽度,即 64 位机器上的 8 个字节。由于我始终将 non_newlines 写入分配中的当前位置,并且其他位串在它之后写入,因此最后我们将只留下 non_newlines 位串。唯一的缺点是我们需要比其他方式多分配 3 个 u64,但这几乎没有任何麻烦。以下是此策略在内存中的外观图:

|0|1|2|3|4|5|6|7|8|9| <- slots
|a|b|c|d|  <- We write our bitstrings to 4 slots. (`a` is `non_newlines`)
 |a|b|c|d| <- Each time, we move one slot forward
  |a|b|c|d|
   |a|b|c|d|
    |a|b|c|d|
     |a|b|c|d|
      |a|b|c|d|
|a|a|a|a|a|a|a|b|c|d| <- In the end, we are left with this

Still to-do

除了 main.zig 文件中列出的待办事项之外,此计划还重写 Zig parser,该 parser 也生成抽象语法树。我对如何显着提高那里的效率也有很多想法。敬请关注!

我的最终目标是将此存储库与 Zig 编译器集成。

How to use

git clone https://github.com/Validark/Accelerated-Zig-Parser.git

接下来,在 src/files_to_parse 文件夹下安装一个或多个 Zig 项目。

cd Zig-Parser-Experiment/src/files_to_parse
git clone https://github.com/ziglang/zig.git
git clone https://github.com/zigtools/zls.git

然后运行它!

cd ../..
zig build -Doptimize=ReleaseFast run

Latest work

在过去的几天里,我:

Example with 32 bit integers:
We want to concentrate the upper bits of each byte into a single nibble.
Doing the gradeschool multiplication algorithm, we can see that each 1 bit
in the bottom multiplicand shifts the upper multiplicand, and then we add all these
shifted bitstrings together. (Note `.` represents a 0)
 a.......b.......c.......d.......
* ..........1......1......1......1
-------------------------------------------------------------------------
 a.......b.......c.......d.......
 .b.......c.......d..............
 ..c.......d.....................
+ ...d............................
-------------------------------------------------------------------------
 abcd....bcd.....cd......d.......
Then we simply shift to the right by `32 - 4` (bitstring size minus the number of relevant
bits) to isolate the desired `abcd` bits in the least significant byte!

|0|1|2|3|4|5|6|7|8|9| <- slots
|a|b|c|d|  <- We write our bitstrings to 4 slots. (`a` is `non_newlines`)
 |a|b|c|d| <- Each time, we move one slot forward
  |a|b|c|d|
   |a|b|c|d|
    |a|b|c|d|
     |a|b|c|d|
      |a|b|c|d|
|a|a|a|a|a|a|a|b|c|d| <- In the end, we are left with this

About

A high-throughput parser for the Zig programming language.

Resources

Readme

License

View license Activity

Stars

96 stars

Watchers

8 watching

Forks

2 forks Report repository

Languages