BEAM 编译器简史

2018年6月18日 · 作者:Björn Gustavsson

这篇博文简要回顾了 Erlang 用于 BEAM 虚拟机的编译器的历史。为了提供一些背景信息,我们将首先快速了解 Erlang 的抽象机。

早期 Erlang 实现的简要概述 #

Prolog 解释器 #

Erlang 的第一个版本于 1986 年用 Prolog 实现。该版本的 Erlang 用于找出哪些语言特性是有用的,哪些不是。新的语言特性可以在几个小时或几天内添加或删除。

JAM(Joe 的抽象机)#

很快就清楚地认识到,Erlang 至少需要快 40 倍才能在实际项目中发挥作用。

1989 年,JAM(Joe 的抽象机)首次实现。 Mike Williams 用 C 语言编写了运行时系统,Joe Armstrong 编写了编译器,Robert Virding 编写了库。

事实证明,JAM 比 Prolog 解释器快 70 倍。成功了吗?

TEAM(Turbo Erlang 抽象机) #

很快就清楚地认识到,Erlang 仍然需要更快才能在实际项目中发挥作用。

因此,Bogumil (“Bogdan”) Hausman 创建了 TEAM(Turbo Erlang 抽象机)。它将 Erlang 代码编译为 C 代码,然后使用 GCC 编译为本机代码。

对于小型项目来说,它明显比 JAM 快。不幸的是,编译速度非常慢,并且编译后的代码大小太大,无法在大型项目中使用。

BEAM(Bogdan 的 Erlang 抽象机)#

Bogumil Hausman 的下一个机器称为 BEAM(Bogdan 的 Erlang 抽象机)。它是一个混合机器,可以执行本机代码和带有线程代码以及解释器。这使得客户可以将他们的时间关键模块编译为本机代码,并将所有其他模块编译为线程 BEAM 代码。线程 BEAM 本身比 JAM 代码快。

Bogdan 最初的 BEAM 编译器与 JAM 共享编译器前端。本质上,当时的前端与当前编译器中的前端执行相同的操作,如迷失在翻译中(探索编译器的前端)中所述。

我没有 Bogdan 最初编译器的源代码,但就我所知,它有三个编译器阶段,将抽象格式转换为线程 BEAM 代码。

  • beam_compile - 将抽象格式转换为 BEAM 指令。

  • beam_optimize - 优化 BEAM 指令。此阶段是强制性的,因为它对 BEAM 指令进行了一些必要的转换。

  • beam_asm - 将符号 BEAM 汇编格式转换为二进制 BEAM 模块。

VEE(Virding 的 Erlang 引擎)#

这里我们必须提及 VEE(Virding 的 Erlang 引擎),原因很快就会清楚。

VEE 是一种实验性实现,与 JAM 和 BEAM 相比,它具有不同的内存模型。VEE 不像 JAM 和 BEAM 那样为每个进程使用单独的堆,而是使用具有实时垃圾收集器的单个共享堆。这使得消息传递速度比 JAM 和 BEAM 快得惊人。

然而,总的来说,与 JAM 相比,速度没有提升。原因可能是单个共享堆降低了缓存命中率。

BEAM 的成熟 #

创建 OTP 小组和 Erlang/OTP 的目的是使 Erlang 工业化,使其适用于大型现实项目。第一个版本 OTP R1B 于 1996 年发布。

这是历史课可能变得有点主观的地方。

我于 1996 年底加入了 Erlang/OTP 团队。我对 Erlang/OTP 的第一个小型代码贡献包含在 OTP R1D 中。

我在 ERTS(Erlang 运行时系统)团队工作,当时由 Kenneth Lundin 领导。最初,我从事 Microsoft Windows 的 Erlang 运行时系统工作。过了一段时间(可能是一年左右),Kenneth 要求我帮助稳定和改进 BEAM。逐渐地,BEAM 成为我的主要职责,当 Bogdan 离开爱立信时,我成为负责 BEAM 解释器和编译器的主要开发人员。

这篇博文极力想涵盖 BEAM 编译器 的历史,但我认为在我们可以了解编译器之前,需要更多的历史背景。

从 OTP R1 到 OTP R5,BEAM 工作的总体目标是使其足够稳定和快速,以便在实际项目中使用。

实现该目标有两个主要障碍

  • BEAM/C,即通过 C 代码实现的本机代码。
  • 数量庞大且不断变化的 BEAM 指令。

BEAM/C 必须消失!#

很快就明显地意识到,BEAM/C(将 Erlang 代码编译为 C 代码的编译器阶段)必须消失。在我开始从事 BEAM 工作时,有三种不同的 BEAM/C 风格:一种用于 Sparc 上的 GCC,一种用于非 sparc CPU(如 Intel x86)上的 GCC,另一种用于不支持 GCC 的标签地址扩展的其他 C 编译器。错误不仅出现在本机代码中,而且 BEAM/C 的存在使线程 BEAM 解释器变得复杂并导致错误。

不幸的是,在我早期改进 BEAM 的职业生涯中,我对 BEAM/C 生成的 C 代码的大小进行了一些优化。后来当我建议我们应该删除 BEAM/C 时,这给我带来了麻烦。大小的改进使得可以将更多编译为本机代码的 Erlang 代码放入系统中,并且本机代码比线程 BEAM 代码快。当时我们的客户(AXD 301 项目)需要 BEAM/C 带来的额外速度提升,并且不允许我们删除 BEAM/C,除非我们可以将线程 BEAM 代码的性能提高到与 BEAM/C 性能相似或更好的水平。

不断变化的 BEAM 指令 #

当时,BEAM 解释器有 300 多条指令。JAM 有一个非常简单的加载器,它本质上只是将 JAM 文件加载到内存中,而 BEAM 的加载器必须将 BEAM 文件中的每个指令从字节格式转换为内存中的线程代码格式。BEAM 有用于加载每个单独指令的手写代码。

更糟糕的是,指令集在不断发展。错误修复和性能改进需要新的指令,而这些指令必须在编译器、线程代码解释器(beam_emu.c 中的 process_main() 函数)和加载器中实现。在 Erlang/OTP 的每个次要和主要版本中,BEAM 的用户都必须重新编译他们所有的 Erlang 代码,因为指令集已更改。

我想,必须有更好的方法。我开始编写一个简单的 Perl 脚本,至少可以自动执行编译器、解释器和加载器中从指令名称到指令编号的映射。 Tony Rogvall 建议我可以更雄心勃勃一些,并使用 Perl 脚本生成加载器的大部分代码。他还建议可以将许多指令的操作数打包到一个字中。这将减少加载代码大小,还可以提高缓存命中率,从而提高执行速度。

所以我开始编写第一个版本的 beam_makeops 脚本并重写加载器。我喜欢增量工作,对始终工作的代码库进行微小的更改。但我无法增量重写加载器,因此我疯狂地编写了两三天,直到我有一个可工作的新的加载器的基本版本。然后我可以稍微放松一下,并稍微更慢地向 beam_makeops 和加载器添加更多功能。

新的加载器接管了一些以前由编译器完成的任务。

例如,BEAM 虚拟机有几个专门的 move 指令。有一个指令用于将某些内容移动到 X 寄存器中,另一个指令用于将原子移动到 X 寄存器中,依此类推。在新的加载器之前,编译器知道 move 指令的所有这些变体,并选择适当的指令。有了新的加载器,编译器只需要关心一个 move 指令,加载器会在加载时选择要使用的适当的专用 move 指令。

编译器完成的另一个较小的优化是组合常见的指令序列。例如,move 指令后跟 call 指令将被组合成 move_call 指令。该优化也已移至加载器。

所有这些功能使得可以显著简化并减少编译器所知的指令数量。更重要的是,这使得保持指令集稳定成为可能(同时仍然允许仅通过调整加载器和解释器来进行微小的优化和性能调整),从而避免了每次发布新版本时都重新编译所有 Erlang 代码的需要。

如果我的记忆没有出错,新的加载器是在 OTP R4 中引入的。

OTP R5B:新的“BEAM” #

继续前进到 OTP R5。

OTP R5 是支持 JAM 的最后一个版本。

OTP R5 也可以说是第一个具有“新” BEAM 的版本。在该版本中,引入了 现代 BEAM 文件格式。今天仍然使用相同的文件格式。当时,有 78 条 BEAM 指令;在 OTP 20 中,有 159 条指令(实际上,有 129 条活动指令和 30 条不再使用的已过时指令)。虽然在需要时引入了新指令,并删除了过时的指令,但始终可以加载从至少两个主要版本之前编译的 BEAM 文件。

线程 BEAM 的执行速度已经足够快,因此可以删除 BEAM/C(我认为已经在 R4 中删除了)。但奇怪的是,客户仍然想要更快的速度。

R5 中的 BEAM 编译器仍然是 Bogdan 最初的编译器。虽然它比 JAM 做了更多的优化,但我们知道可以进行更多的优化。

R6B:进入内核 Erlang #

与此同时,在顶层,Robert Virding 正忙于为他的 VEE 机器编写一个新的编译器。在该新编译器中,Robert 引入了一种称为 Kernel Erlang 的新中间格式。这个想法是在为实际机器生成代码之前,可以对该格式的代码应用更多优化。

当时,没有实际的解释器可以执行他的新编译器发出的代码(他还没有更新 VEE 机器)。他心目中的机器是一台寄存器机器。它与 BEAM 类似,只是它会进行堆栈修剪。

我们想要从 Robert 的编译器中获得更好的性能,但问题是:我们应该实现一个新的解释器(或调整 BEAM)来执行 Robert 编译器中的代码,还是应该调整 Robert 的编译器来生成 BEAM 代码?

由于我们首次拥有了 BEAM 的稳定实现,我们决定不再进行大的改动;因此,我们决定由我来适配 Robert 的编译器中的代码生成器部分,使其适用于 BEAM。

在很大程度上,我沿用了 Robert 的指令名称。例如,在原始的 BEAM 中,将一个项加载到寄存器的指令称为 M,而 Robert 的编译器使用 move。更主要的更改是在堆栈的处理上。Robert 的编译器具有堆栈修剪功能,我必须将其移除并重写,以处理 BEAM 的固定堆栈帧。(我稍后重新引入了有限形式的堆栈修剪。)

由于 OTP R6 中不支持 JAM,所有之前使用 JAM 的客户都必须迁移到 BEAM。为了尽可能降低迁移的风险,我们的一位客户要求我们在 OTP R6 中提供经过实战检验的原始 BEAM 编译器作为选项。

因此,我们添加了选项来选择要使用的编译器版本。要使用旧编译器,需要编写以下代码:

$ erlc +v1 some_module.erl

默认情况下是 Robert 的新编译器,称为 v2。还有一个未公开的非官方编译器版本,称为 v3

所有编译器都共享前端和生成最终 BEAM 模块的 beam_asm 阶段。

v1_编译器 #

v1 编译器具有以下阶段

  • v1_adapt
  • v1_compile
  • v1_optimize
  • v1_cleanup

v1_compilev1_optimize 阶段本质上是 Bogdan 编译器中的 beam_compilebeam_optimize 阶段。

自 R5 以来,前端发生了一些变化,因此 v1_adapt 阶段的作用是为 v1_compilev1_optimize 阶段隐藏这些变化。v1_cleanup 阶段是一个额外的次要优化阶段;我认为它在 OTP R5 中也存在。

v2_编译器 #

v2 编译器是 Robert 的新编译器。它具有以下阶段

  • v2_kernel
  • v2_kernopt
  • v2_match
  • v2_life
  • v2_codegen

v2_kernel 阶段将抽象格式转换为 Kernel Erlang。

v2_kernopt 对 Kernel Erlang 代码进行非常基本的优化,本质上只有常量传播和常量折叠

v2_match 执行模式匹配编译。JAM 会顺序匹配函数头或 case 表达式中的子句。旧的 BEAM 编译器在这方面做得稍微好一些,它可以在单个指令中匹配多个整数或原子。Robert 的编译器是第一个使用 函数式编程语言的实现 中 Simon Peyton Jones 描述的算法来正确编译模式匹配的 Erlang 编译器。

v2_life 会计算 v2_codegen 阶段所需的生命周期信息,而 v2_codegen 会生成 BEAM 汇编代码。

R7B:进入 Core Erlang #

与此同时,Richard Carlsson 和乌普萨拉大学的 HiPE 小组提出了一个新中间格式的想法,该格式可用作不同 Erlang 实现的交换格式,并用于优化 Erlang 程序。

新格式称为 Core Erlang。Robert 喜欢这个想法,并开始在编译器中实现 Core Erlang。OTP R6 中未公开的 v3 编译器的实现基于 Core Erlang 规范的草案版本。

在 OTP R7B 中,v1 和 v2 编译器被删除,唯一保留的编译器是使用 Core Erlang 的 v3 编译器。它具有以下阶段

  • v3_core
  • v3_core_opt
  • v3_kernel
  • v3_life
  • v3_codegen

v3_core 阶段将抽象格式转换为 Core Erlang。

v3_core_opt 阶段本质上只调用了 sys_core_fold,它执行常量传播和常量折叠sys_core_fold 仍然执行这些操作,以及更多操作。

其余阶段执行的操作与今天相同。

v3_kernel 阶段从 Core Erlang 转换为 Kernel Erlang,并且还进行模式匹配编译(方式与 v2_match 中相同)。v2_kernopt 中的优化现在在 sys_core_fold 中完成。

v3_life 阶段(尽管名称如此)不再计算生命周期信息。生命周期信息改为由 v3_kernel 计算,并作为注解传递。

v3_life 仍然存在的原因是 Robert 继续开发他自己的 codegen 版本,该版本没有我为 BEAM 工作而进行的所有更改。在实现 Core Erlang 阶段的同时,他还对 codegen 进行了许多改进。

当到了集成我们不同版本的编译器时,Robert 惊恐地看着我在 codegen 中的所有更改。为了避免将我为 BEAM 所做的所有适配和优化重新引入到他的新版本 codegen 中,Robert 编写了一个适配器阶段,该阶段将新的 Kernel Erlang 格式转换为旧格式,以便我的 codegen 可以工作。适配器阶段称为 v3_life

因此,v3_codegen 本质上是名称更改为 v2_codegen

在即将推出的 OTP 21 中,v3_life 已与 v3_codegen 合并。

向 Robert 学习 Erlang #

在我和 Robert 一起开发编译器的这段时间里,我通常负责 v3_codegen 和下面的阶段,而 Robert 则负责 v3_codegen 以上的所有阶段。

偶尔,我会向 sys_core_fold 添加一些优化,然后将其交给 Robert,以便将其整合到他最新版本的 sys_core_fold 中。

然后我会查看 Robert 对我的代码所做的修改,并从中学习。

通常 Robert 会巧妙地改进我的代码,使其更简洁、更简单。但有一次,我交给 Robert 一个 case 子句的优化。我收到的代码非常不同。Robert 将我的优化分解为几个更简单的优化,这些优化实现了与我更复杂的优化相同的目的(甚至更多)。