JIT 初探

2020 年 11 月 03 日 · 作者:John Högberg

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

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

多年来,我们曾多次尝试开发 JIT,目标远大,但最终都失败了。我们最新的,也是迄今为止最成功的尝试,选择了简单,牺牲了生成代码中的一些低效率,换取了易于实现。如果我们排除我们使用的运行时汇编器库 asmjit,整个 JIT 的大小大致与解释器相当。

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

  1. 所有模块始终编译为机器代码。

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

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

  2. 数据只能在指令之间保存在 BEAM 寄存器中传递。

    这似乎很傻,难道机器寄存器不是更快吗?

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

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

  3. 模块一次编译一条指令。

    在我们之前的尝试中,最困难的问题之一是如何在编译某些东西所需的时间和执行编译的积极性之间取得良好的平衡。如果我们过于积极,我们将花费太多时间进行编译,如果我们过于懈怠,我们将看不到任何改进。

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

    通过限制自己一次只编译一条指令,我们放弃了一些性能,但大大提高了编译速度。

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

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

    乍一看这似乎令人望而生畏,但一旦你习惯了它,其实也没那么糟糕。虽然要实现哪怕是最细微的事情也需要大量的代码,但只要代码保持简短,它本质上是简单易懂的。

    缺点是每条指令都需要为每个架构实现,但幸运的是,流行的架构不多,我们希望在发布 OTP 24 时支持最常见的两种:x86_64AArch64。其他架构将继续使用解释器。

在编译模块时,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 寄存器 1”,我们的失败标签是 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));
}

这个小小的逃生舱使我们免于从一开始就用汇编语言编写所有内容,并且许多指令仍然像这样,因为没有必要更改它们。

今天就到这里。在下一篇文章中,我们将介绍我们的约定以及我们为减少代码大小而使用的一些技术。