解释器优化

2018年6月11日 · 作者:Lukas Larsson

erts 中的 BEAM 解释器 在 OTP 21 中被完全重写。大部分指令保持不变,但是用于生成 C 代码的 perl 脚本采用了新的实现。这篇博文将介绍由于这些更改而可能实现的一些优化。

首先,让我们从一些关于解释器如何构建的基础知识开始。BEAM 解释器是使用生成的 C 代码构建的。分派是使用直接线程完成的,它利用了 GCC 扩展标签作为值beam_makeops perl 脚本的任务是获取输入文件并创建 C 文件。

还生成了一组转换规则,供 beam 代码加载器中的transform_engine使用,以执行多项窥孔优化。优化包括但不限于指令组合、指令专门化和死代码消除。

使用了三种不同的输入文件类型。

  • genop.tab 包含编译器发出的指令列表。
  • ops.tab 包含对代码执行的加载时转换。
  • instrs.tab 包含每个指令的实现。

内部 beam_makeops 文档中描述了不同文件的语法和语义。新的代码生成方式和旧方式之间的最大区别在于,在 OTP 21 中,所有 指令现在都已生成,而不是大约 75%。这使我们能够在生成指令代码时为所有指令执行特定于架构的优化。

打包分派目标地址 #

在 64 位机器上,所有指针都是 8 字节大小,当你获取标签的地址时得到的指针也是如此。因此,对于像 move_cx (将常量移动到 x 寄存器)这样的小指令,需要 3 个字的内存。

     +--------------------+--------------------+
I -> |                            &&lb_move_cx |
     +--------------------+--------------------+
     |                        Tagged atom 'id' |
     +--------------------+--------------------+
     |                                      40 |
     +--------------------+--------------------+

一个字用于指令,一个字用于字面量,然后目标 x 寄存器实际上只需要 2 个字节,但是由于所有代码都是按字对齐的,因此它有自己的字。

但是,在大多数机器上,链接器使用所谓的“小代码模型”或“中代码模型”。这意味着它会将所有代码放置在地址空间的较低 2 GB 中,以便可以使用更高效的机器指令。它也有利于 beam 解释器,因为我们现在知道指令字的较高 4 个字节将始终为 0。

     +--------------------+--------------------+
I -> |                 40 |       &&lb_move_cx |
     +--------------------+--------------------+
     |                        Tagged atom 'id' |
     +--------------------+--------------------+

因此,我们没有将要使用的 x 寄存器放置在它自己的字中,而是将其打包到指令字中,从而为该指令节省了一个字的内存。

该优化只能在将所有代码放置在地址空间较低 4 GB 中的 64 位平台上进行,因此 32 位和位置无关可执行文件平台不使用它。为了弄清楚我们可以在哪些 64 位平台上进行此操作,我们首先编写了一个配置脚本,该脚本查看 CFLAGS 和 LDFLAGS,询问编译器默认情况下会执行什么操作等等。经过一段时间的调整,我们提出了一个更简单且迄今为止稳定的解决方案

#include <stdlib.h>
int main() {
  if ((unsigned long long)&main < (1ull << 32)) {
    exit(0);
  }
  exit(1);
}

似乎位置无关可执行文件始终将代码放置在 > 4GB 的段中,因此我们只需检查它在小型测试程序中将 main 放在哪里即可。

将参数打包到指令字中对于大量指令是可能的,这减少了加载的代码大小,从而提高了性能。

更智能的打包 #

beam_makeops 中的打包引擎使用几种不同的启发式方法来确定应将哪些指令参数放入指令字中。由于对齐要求和其他原因,只允许某些类型的参数放在一起。在选择如何打包参数时,打包程序首先构建所有可能的不同变体,然后选择占用最少内存的指令。此逻辑可以在do_pack_one函数中找到。在 OTP-21 之前,必须手动对所有指令执行此操作,这意味着实现者在决定应将参数放置在ops.tab中的哪个顺序时必须格外小心。此外,OTP-21 之前的打包逻辑不会将大小不同的参数打包到同一个机器字中。

但是,打包程序并不完美,因此在某些情况下,我们需要能够覆盖其决策。这就是引入?类型修饰符的原因。 ?类型修饰符用于确定指令是否可能使用参数。

如何确定是否可能使用参数?对于某些指令来说,这是显而易见的,例如,仅当触发垃圾回收时才使用 allocate 指令的一个参数,因此不太可能使用它。在其他情况下,这一点不是很明显,例如,测试指令的失败标签,是否有可能使用它?对于大多数测试指令,它不太可能被使用,或者至少比指令的其他参数更不可能被使用,因为它们用于实际测试中。

为什么将较少使用的指令打包到指令字中更好?如果始终使用的参数在同一个字中,则 GCC 对于大多数指令会生成稍微好一点的代码,我们通过查看 GCC 生成的汇编代码和解释器的总代码大小都看到了这一点。我们没有能够测量到由于不太可能的指令是如何打包的而导致的性能差异,但是较小的代码始终是件好事。

相对跳转标签 #

许多指令中都有分支,这些分支将根据各种条件执行。最简单的之一是is_eq_exact_immed 指令。如果该值等于参数中的立即字面量,它将继续执行下一条指令,否则将跳转到失败标签。在 OTP-21 之前,指令 is_eq_exact_immed_frc 将具有此布局

     +--------------------+--------------------+
I -> |              &&lb_is_eq_exact_immed_frc |
     +--------------------+--------------------+
     |                 Pointer To failure code |
     +--------------------+--------------------+
     |                       Tagged immed 'id' |
     +--------------------+--------------------+

该指令的 C 代码如下所示(我已删除 beam_makeops 使用的所有宏)

if (reg[0] != I[2]) {
  I = I[1];
  goto *(void**)I;
}
I+=3;
goto *(void**)I;

在此示例中,使用指令字的一部分没有帮助,因为两个参数都是 8 个字节大。我们也无法使用与指令字相同的技巧来缩小失败标签,因为 Erlang 代码可以分配在 64 位地址空间的任何位置。但是,我们可以依赖的一件事是,单个 Erlang 模块的代码将位于连续的内存区域中。因此,我们没有使用指向要跳转到的代码的指针,而是可以使用同一模块内跳转的相对地址。

使用相对本地跳转的问题在于,我们限制了模块的大小。例如,Erlang/OTP 中最大的模块是 ‘OTP-PUB-KEY’ 模块,加载时它的大小为 61585 字,最长的本地跳转是 5814 字。对于此特定模块,我们可以使用 13 位跳转标签,或者如果我们想确保所有函数都可以相互调用,则可以使用 16 位。 16 位将是一个完美的大小,因为它可以与指令字中的另一个寄存器一起打包。因此,有可能在 8 个字节中容纳指令地址 + 跳转标签 + 寄存器。但是,这太接近舒适区了,而且在其他系统中肯定会有比我们更大的模块,因此我们决定对跳转标签使用 32 位。因此,在 OTP-21 中,加载的模块的最大大小已从 2^32 GB 变为 32 GB,这对于大多数用例应该足够了。

使用此新布局,可以将 is_eq_exact_immed_frc 指令重写为使用以下布局

     +--------------------+--------------------+
I -> | Offset to failure..| &&lb_is_eq_exac... |
     +--------------------+--------------------+
     |                       Tagged immed 'id' |
     +--------------------+--------------------+

并生成此代码

if (reg[0] != I[1]) {
  I += I[0] >> 32;
  goto *(void**)(Uint64)(Uint32)I;
}
I+=2;
goto *(void**)(Uint64)(Uint32)I;

代码最终变得稍微复杂一些,但是 C 编译器会设法将其优化为非常高效的代码。基本上,与以前相比,每个跳转标签都有一个额外的加法运算。在分析时,这种额外的代码并不明显,因为它被从(希望)l1 缓存加载的值所淹没。

许多指令都从此优化中受益,因为它们中的许多指令都控制着控制流。这在大型select_val_bins上尤其明显,因为它们的内存使用量减少了 25%。而且,您可能已经注意到,它与指令字的打包和?类型修饰符beam_makeops中配合得非常好。