初识Erlang JIT

现在我们已经了解了BEAM解释器,我们将探索 OTP 24 中最令人兴奋的新增功能之一:即时编译器,或简称为“JIT”。

如果你和我一样,“JIT”这个词可能会让你想到 Hotspot (Java) 或 V8 (Javascript)。这些是非常令人印象深刻的工程,但它们似乎劫持了这个术语;并非所有的 JIT 都那么复杂,也不是为了快速而必须如此复杂。

多年来,我们为了获取成功在 JIT 上进行了许多尝试。我们最新的也是迄今为止最成功的尝试是以简单为主,而不是在生成的代码中以轻微的低效率来换取易于实施。如果我们排除我们使用的运行时汇编程序库asmjit,那么整个东西大致与解释器一样大。

我相信我们的成功很大程度上归功于我们在项目早期的四个想法:

所有模块总是编译成机器码。

以前的尝试(以及 HiPE)在解释器和机器代码之间切换时遇到了困难:要么太慢,要么太难维护,要么两者兼而有之。

始终运行机器代码意味着我们永远不必切换。

在指令之间数据只在BEAM 寄存器中保存(传递)。

这可能看起来很愚蠢,机器寄存器不是更快吗?

是的,但实际上并没有太快,这会使事情变得更加复杂。通过始终在 BEAM 寄存器中传递数据,我们可以使用 Erlang 编译器提供给我们的寄存器分配策略,从而使我们不必在运行时执行这个非常昂贵的步骤。

更重要的是,从运行时系统的角度来看,这最小化了解释器和 JIT 之间的差异。

模块一次编译一条指令。

在我们之前的尝试中,最困难的问题之一是在编译某些东西所花费的时间和这样做的渴望之间取得良好的平衡。如果我们太急切,我们将花费太多时间编译,如果我们太松懈,我们将看不到任何改进。

这个问题很大程度上是自己造成的,并且是由于编译器太慢(我们经常使用 LLVM)造成的,而我们给它提供了大量代码以允许更多优化,这使情况变得更糟。

通过将自己限制为一次编译一条指令,我们保留了一些性能,但大大提高了编译速度。

每条指令都有一个手写的机器代码模板。

这使得编译非常快,因为我们基本上只是在每次使用指令时复制粘贴模板,只根据其参数执行一些小的调整。

起初这可能看起来令人生畏,但一旦你习惯了它实际上并没有那么糟糕。虽然实现即使是最小的事情肯定需要大量代码,但只要代码保持简短,它本质上就很简单且易于遵循。

缺点是每个架构都需要实现每条指令,但幸运的是没有很多流行的指令集,我们希望在我们发布 OTP 24 时支持两个最常见的指令:x86_64 和AArch64. 其他人将继续使用解释器。

编译模块时,JIT 会一一执行指令,同时调用机器代码模板。与解释器相比,这有两个非常大的好处:不需要在它们之间跳转,因为它们是背靠背发出的,并且每个的结束都是下一个的开始,并且不需要在运行时解决参数问题,因为它们已经“烧毁”了。

现在我们有了一些背景知识,让我们看一下上一篇文章中示例的机器代码模板is_nonempty_list:

/* Arguments are passed as 'ArgVal' objects which hold a
 * type and a value, for example saying "X register 4",
 * "the atom 'hello'", "label 57" and so on. */
void BeamModuleAssembler::emit_is_nonempty_list(const ArgVal &Fail,
                                                const ArgVal &Src) {
    /* Figure out which memory address 'Src' lives in. */
    x86:Mem list_ptr = getArgRef(Src);

    /* Emit a 'test' instruction, which does a non-
     * destructive AND on the memory pointed at by
     * list_ptr, clearing the zero flag if the list is
     * empty. */
    a.test(list_ptr, imm(_TAG_PRIMARY_MASK - TAG_PRIMARY_LIST));

    /* Emit a 'jnz' instruction, jumping to the fail label
     * if the zero flag is clear (the list is empty). */
    a.jnz(labels[Fail.getValue()]);

    /* Unlike the interpreter there's no need to jump to
     * the next instruction on success as it immediately
     * follows this one. */
}

此模板将生成看起来与模板本身几乎相同的代码。假设我们的源是“X register 1”,我们的fail lable是 label_57:

test qword ptr [rbx+8], _TAG_PRIMARY_MASK - TAG_PRIMARY_LIST
jnz label_57

这比解释器快得多,甚至比线程代码更紧凑,但这是一条微不足道的指令。更复杂的呢?让我们看一下timeout解释器中的指令:

timeout() {
    if (IS_TRACED_FL(c_p, F_TRACE_RECEIVE)) {
        trace_receive(c_p, am_clock_service, am_timeout, NULL);
    }
    if (ERTS_PROC_GET_SAVED_CALLS_BUF(c_p)) {
        save_calls(c_p, &exp_timeout);
    }
    c_p->flags &= ~F_TIMO;
    JOIN_MESSAGE(c_p);
}

那肯定是很多代码,而且那些宏手动转换会很烦人。我们到底要如何做到这一点而不失去理智?

通过作弊,就是这样:D

static void timeout(Process *c_p) {
    if (IS_TRACED_FL(c_p, F_TRACE_RECEIVE)) {
        trace_receive(c_p, am_clock_service, am_timeout, NULL);
    }
    if (ERTS_PROC_GET_SAVED_CALLS_BUF(c_p)) {
        save_calls(c_p, &exp_timeout);
    }
    c_p->flags &= ~F_TIMO;
    JOIN_MESSAGE(c_p);
}

void BeamModuleAssembler::emit_timeout() {
/* Set the first C argument to our currently executing
* process, c_p, and then call the above C function. */
a.mov(ARG1, c_p);
a.call(imm(timeout));
}

这个出口使我们不必从一开始就用汇编程序编写所有内容,并且许多指令仍然如此,因为没有任何改变它们的意义。

这就是今天的全部内容。在下一篇文章中,我们将介绍我们的约定和我们用来减少代码大小的一些技术。

Related Posts

2021 年你需要知道的关于 Erlang 的一切

今天,我们将看一个相当古老且有些古怪的东西。 你们大多数人可能没有注意到的语言。 虽然 Erlang 不像某些现代编程语言那样流行,但它安静地运行着 WhatsApp 和微信等每天为大量用户提供服务的应用程序。 在这篇文章中,我将告诉你关于这门语言的更多事情、它的历史,以及你是否应该考虑自己学习它。 ## 什么是 Erlang,它在哪里使用? Erl

Read More

Erlang JIT中基于类型的优化

这篇文章探讨了 Erlang/OTP 25 中基于类型的新优化,其中编译器将类型信息嵌入到 BEAM 文件中,以帮助JIT(即时编译器)生成更好的代码。 ## 两全其美 OTP 22 中引入的基于SSA的编译器处理步骤进行了复杂的类型分析,允许进行更多优化和更好的生成代码。然而,Erlang 编译器可以做什么样的优化是有限制的,因为 BEAM 文件必须

Read More

Erlang JIT之路

自从Erlang 存在,就一直有让它更快的需求和野心。这篇博文是一堂历史课,概述了主要的 Erlang 实现以及如何尝试提高 Erlang 的性能。 ## Prolog 解释器 Erlang 的第一个版本是在 1986 年在 Prolog 中实现的。那个版本的 Erlang 对于创建真正的应用程序来说太慢了,但它对于找出Erlang语言的哪些功能有用,哪

Read More