假如我们有一段某种编程语言的 stack-based 字节码:
const1 ; 将常量 1 压入栈顶
const3
add
const3
mult
halt
在一个经典的 C 编写的解释器中,我们可能会有一个用类似 switch 的东西实现的循环,来解释执行这段计算 (3 + 1) * 3 的字节码。
enum {
OP_CONST1,
OP_CONST2,
OP_CONST3,
OP_ADD,
OP_MULT,
OP_HALT,
};
void interpret(uint8_t insn[], uint32_t stack[]) {
int pc = 0;
uint32_t *st = stack;
next:
switch (insn[pc++]) {
case OP_CONST1:
*st++ = 1; goto next;
case OP_CONST2:
*st++ = 2; goto next;
case OP_CONST3:
*st++ = 3; goto next;
case OP_ADD:
st[-2] = st[-1] + st[-2];
st--; goto next;
case OP_MULT:
st[-2] = st[-1] * st[-2];
st--; goto next;
case OP_HALT:
return;
}
}
有很多原因导致这段代码性能不佳:
- 由于 C 标准的原因,switch 会插入额外的条件检查指令
- 如果在优化后使用了跳转表,我们需要先从栈顶读取一条 op,再用 op 作为索引从跳转表加载一个 native 地址,然后再间接跳转到这个地址。
- 分支预测的缺陷:字节码 中的 op 以不同频率出现,但 switch 只有一个入口处的跳转用于指令 dispatch。这对 CPU 的基于跳转历史的分支预测器是致命的。
为了改进 switch dispatch 的缺陷,可以使用 GNU C 的 label-as-value 和computed-goto 拓展:
- 不再有额外的范围检查。
- 改进的分支预测:每条 op 之后都会有一个跳转,而不像 switch 那样在入口处集中跳转。这稍微有利于分支预测器的工作。
- 然而,我们仍然需要多次内存加载以获取下一条指令地址。
enum {
OP_CONST1,
OP_CONST2,
OP_CONST3,
OP_ADD,
OP_MULT,
OP_HALT,
};
void interpret(uint8_t insn[], uint32_t stack[]) {
static void *ops[] = {
&&L_CONST1, &&L_CONST2, &&L_CONST3, &&L_ADD, &&L_MULT, &&L_HALT,
};
long pc = 0;
uint32_t *st = stack;
goto *ops[insn[pc++]];
L_CONST1:
*st++ = 1;
goto *ops[insn[pc++]];
L_CONST2:
*st++ = 2;
goto *ops[insn[pc++]];
L_CONST3:
*st++ = 3;
goto *ops[insn[pc++]];
L_ADD:
st[-2] = st[-1] + st[-2];
st--;
goto *ops[insn[pc++]];
L_MULT:
st[-2] = st[-1] * st[-2];
st--;
goto *ops[insn[pc++]];
L_HALT:
return;
}
如果实现了完全体的 direct threading,还可以进一步减少内存读取的次数。概念上而言,这需要将 字节码 序列从 {OP_CONST1, OP_CONST3, OP_ADD, OP_CONST3, OP_MULT, OP_HALT}
替换为 { &&L_CONST1, &&L_CONST3, &&L_ADD, &&L_CONST3, &&L_MULT, &&L_HALT}
。这样我们即可减少一次内存寻址与读取。
有人观察到,在上面的循环中,解释器循环仍让 CPU 承受了巨大的分支预测失败的风险。尽管进行指令 dispatch 的位置从 switch 入口处分散到了每个 op 实现的末尾,但还是很不好进行预测。例如,参考最开始提到的字节码,我们两次使用了 const3
这个 op,然而它们的后继并不相同。这意味着分支预测器可能会在 const3
尾部的 dispatching jump 上频频预测失败。对于一个希望尽量跑的快的程序来说,如此之多的预测失败是令人难以接受的。由于这个问题产生的根本原因是物理机的 PC 和 VM 的 PC 缺乏相关性所导致的,所以我们也将这个问题称作 context problem。
Template-based JIT
也被称作 Template Interpreter,HotSpot VM 就使用了这个说法。
针对经典解释器上出现的问题问题,可以这样想:既然单个 op 的处理代码最后的跳转会由于实际字节码序列中同一 op 出现在不同的指令之前导致预测失败,那我们应该直接为每个不同位置的 op 都按照输入字节码的顺序生成处理代码和跳转的副本。这样可以做到每个 indirect jump 既对应了特定的 native 地址,又对应了虚拟指令的控制流,从而使得 CPU 可以充分利用硬件预测机制。而且,注意到在生成副本后,字节码中线性的控制流之间的 op 我们也不再需要间接跳转了,进一步提高了了性能。现在生成的代码中的跳转恰好对应于字节码控制流中的跳转。
生成处理代码副本的办法很简单,就是提前生成一堆对应体系结构的二进制代码也就是 template,然后运行时简单根据字节码序列的顺序把这些代码粘贴起来。例如,假设 VM 的 stack pointer 始终被放在 rsi 寄存器,我们可以为上面提到的字节码提前生成好以下模板。
OP_CONST1:
mov dword ptr [rsi], 1
add rsi, 4
----------------------------------------
OP_CONST2:
mov dword ptr [rsi], 2
add rsi, 4
----------------------------------------
OP_CONST3:
mov dword ptr [rsi], 3
add rsi, 4
----------------------------------------
OP_ADD:
mov eax, dword ptr [rsi-4]
add dword ptr [rsi-8], eax
sub rsi, 4
----------------------------------------
OP_MULT:
mov eax, dword ptr [rsi-8]
imul eax, dword ptr [rsi-4]
mov dword ptr [rsi-8], eax
sub rsi, 4
----------------------------------------
OP_HALT:
ret
运行时,我们根据字节码,把上面模板拼接起来:
mov dword ptr [rsi], 1
add rsi, 4
mov dword ptr [rsi], 3
add rsi, 4
mov eax, dword ptr [rsi - 4]
add dword ptr [rsi - 8], eax
sub rsi, 4
mov dword ptr [rsi], 3
add rsi, 4
mov eax, dword ptr [rsi-8]
imul eax, dword ptr [rsi-4]
mov dword ptr [rsi-8], eax
sub rsi, 4
ret
这样就生成一段 (相对高效的) 使用本机代码执行字节码语义的代码。为了简单起见我们忽略了分支等操作,一方面这段代码几乎是原本字节码 op 的一对一翻译,仍然离不开对解释器流程的模仿;另一方面由于生成了 native 代码,似乎也属于 Just-In-Time 编译的范畴,所以产生了 Template-based JIT 与 Template Interpreter 两种不同的称谓。