JIT 的进一步探索

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

这篇文章继续我们对 JIT 的探索,更深入地挖掘实现细节。

虽然用机器代码(汇编语言)编写程序可以给你很大的自由,但代价是你必须自己发明几乎所有的东西,而且没有聪明的编译器来帮助你捕捉错误。例如,如果你以某种方式调用一个函数,而该函数并不期望这样,那么你最好会使你的操作系统进程崩溃,或者最坏的情况是花费数小时追逐一个 海森堡bug

因此,在编写汇编程序时,约定 始终是核心,所以我们需要在继续前进之前了解一些我们选择的约定。

最重要的一个是关于寄存器的,我们基于系统调用约定来使其更容易调用 C 代码。我已经包括了下面 Linux 上使用的 SystemV 约定的表格。在其他系统(如 Windows)上,寄存器会有所不同,但原理在所有系统上都是相同的。

寄存器 名称 被调用者保存 用途
RDI ARG1 第一个参数
RSI ARG2  
RDX ARG3  
RCX ARG4  
R8 ARG5  
R9 ARG6 第六个参数
RAX RET 函数返回值

因此,如果我们想调用一个带有两个参数的 C 函数,我们在调用它之前将第一个参数移动到 ARG1 中,将第二个参数移动到 ARG2 中,当它返回时,我们将在 RET 中获得结果。

除了说明哪些寄存器用于传递参数之外,调用约定还说明哪些寄存器在函数调用中保留其值。这些被称为“被调用者保存”寄存器,因为被调用的函数如果修改了它们,则需要保存并恢复它们。

在这些寄存器中,我们保留 C 代码中很少(如果曾经)更改的常用数据,这有助于我们避免在每次调用 C 代码时保存和恢复它们

寄存器 名称 被调用者保存 用途
RBP active_code_ix 活动代码索引
R13 c_p 当前进程
R15 HTOP 当前进程堆的顶部
R14 FCALLS 归约计数器
RBX registers BEAM 寄存器结构

我们还将当前进程的堆栈保存在 RSP 中,即机器堆栈指针,以允许在 Erlang 代码中使用 callret 指令。

这样做的缺点是我们不能再调用任意 C 代码,因为它可能假定更大的堆栈,这需要我们在“C 堆栈”和“Erlang 堆栈”之间来回切换。

在我之前的文章中,我们调用了一个 C 函数(timeout)而没有做任何这些,这有点像一个善意的谎言。在我们更改堆栈的工作方式之前,曾经是那样做的,但它仍然非常简单,如下所示

void BeamModuleAssembler::emit_timeout() {
    /* Swap to the C stack. */
    emit_enter_runtime();

    /* Call the `timeout` C function.
     *
     * runtime_call compiles down to a single `call`
     * instruction in optimized builds, and has a few
     * assertions in debug builds to prevent mistakes
     * like forgetting to switch stacks. */
    a.mov(ARG1, c_p);
    runtime_call<1>(timeout);

    /* Swap back to the Erlang stack. */
    emit_leave_runtime();
}

由于我们在设置 registers 时使用了一个技巧,所以切换堆栈非常便宜:通过在 *C 堆栈* 上分配结构,我们可以从 registers 计算出该堆栈的地址,这避免了保留一个宝贵的被调用者保存寄存器,并且比将其保存在内存中的某个地方要快得多。

在了解了约定之后,我们可以再次开始查看代码了。这次我们选择一个更大的指令,test_heap,它会分配堆内存

void BeamModuleAssembler::emit_test_heap(const ArgVal &Needed,
                                         const ArgVal &Live) {
    const int words_needed = (Needed.getValue() + S_RESERVED);
    Label after_gc_check = a.newLabel();

    /* Do we have enough free space already? */
    a.lea(ARG2, x86::qword_ptr(HTOP, words_needed * sizeof(Eterm)));
    a.cmp(ARG2, E);
    a.jbe(after_gc_check);

    /* No, we need to GC.
     *
     * Switch to the C stack, and update the process
     * structure with our current stack (E) and heap
     * (HTOP) pointers so the C code can use them. */
    emit_enter_runtime<Update::eStack | Update::eHeap>();

    /* Call the GC, passing how many words we need and
     * how many X registers we use. */
    a.mov(ARG2, imm(words_needed));
    a.mov(ARG4, imm(Live.getValue()));

    a.mov(ARG1, c_p);
    load_x_reg_array(ARG3);
    a.mov(ARG5, FCALLS);
    runtime_call<5>(erts_garbage_collect_nobump);
    a.sub(FCALLS, RET);

    /* Swap back to the Erlang stack, reading the new
     * values for E and HTOP from the process structure. */
    emit_leave_runtime<Update::eStack | Update::eHeap>();

    a.bind(after_gc_check);
}

虽然这并不太复杂,但仍然是一段相当大的代码:由于所有指令都直接发射到它们各自的模块中,像这样的微小低效率往往会迅速膨胀模块。除了使用更多 RAM 之外,这还会浪费宝贵的指令缓存,因此我们花费了大量时间和精力来减少代码大小。

我们减少代码大小最常见的方法是将尽可能多的指令分解成一个全局共享的部分。让我们看看如何应用这种技术

void BeamModuleAssembler::emit_test_heap(const ArgVal &Needed,
                                         const ArgVal &Live) {
    const int words_needed = (Needed.getValue() + S_RESERVED);
    Label after_gc_check = a.newLabel();

    a.lea(ARG2, x86::qword_ptr(HTOP, words_needed * sizeof(Eterm)));
    a.cmp(ARG2, E);
    a.jbe(after_gc_check);

    a.mov(ARG4, imm(Live.getValue()));

    /* Call the global "garbage collect" fragment. */
    fragment_call(ga->get_garbage_collect());

    a.bind(after_gc_check);
}

/* This is the global part of the instruction. Since we
 * know it will only be called from the module code above,
 * we're free to assume that ARG4 is the number of live
 * registers and that ARG2 is (HTOP + bytes needed). */
void BeamGlobalAssembler::emit_garbage_collect() {
    /* Convert ARG2 to "words needed" by subtracting
     * HTOP and dividing it by 8.
     *
     * This saves us from having to explicitly pass
     * "words needed" in the module code above. */
    a.sub(ARG2, HTOP);
    a.shr(ARG2, imm(3));

    emit_enter_runtime<Update::eStack | Update::eHeap>();

    /* ARG2 and ARG4 have already been set earlier. */
    a.mov(ARG1, c_p);
    load_x_reg_array(ARG3);
    a.mov(ARG5, FCALLS);
    runtime_call<5>(erts_garbage_collect_nobump);
    a.sub(FCALLS, RET);

    emit_leave_runtime<Update::eStack | Update::eHeap>();

    a.ret();
}

虽然我们不得不编写同样多的代码,但复制到模块中的部分要小得多。

在我们的下一篇文章中,我们将暂停对实现细节的讨论,并回顾此 JIT 背后的历史。