返回资讯中心
外部精选
软件工程
#开发者实时流

用Java编写的解释器性能追平手写汇编

来源标题:Matching the Speed of Hand-Written Assembly with an Interpreter Written in Java

Yudi Zheng在Crema解释器上验证了一个假设:为每个字节码handler找到局部最优汇编后,组合起来可得到全局最优解释器,性能追平手写汇编,同时Java源码保持可维护。实验从最简单的iconst_0字节码开始分析。

Hacker News 最新mike_hearn2026/11/2 · 今天30 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

编译器 解释器 性能 追寻完美的汇编 用 Java 编写的解释器达到手写汇编的速度 Yudi Zheng 33 分钟阅读· 刚刚

此前,在“逃离巨型 switch”那一期中,我们学习了如何将一个庞大的解释器编译单元拆分成许多独立编译的字节码处理器。

这给我们留下了一个颇有吸引力的假设:如果我们能为每个处理器找到局部最优的汇编,那么把这些处理器组合起来,就应该能得到一个全局最优的解释器。

我们用 Crema 解释器检验了这个假设,但有一个约束条件:它的 Java 源代码必须保持可维护性。

作为编译器工程师,我们偶尔会怀有一种略显傲慢的信念:程序员应该编写直白的 Java 代码,而我们极其聪明的编译器应该处理其余的事情。

但当生成的汇编不尽如人意时,应该由谁来改变呢?

是编译器应该学习一种更通用的优化,还是解释器应该暴露一个只有它自己才知道的事实?

让我们从 iconst_0 开始,这是最简单的 Java 字节码之一。它将整数零压入操作数栈。

对于这个例子,我们只需要解释器帧和栈状态的一个紧凑模型:

class Frame { /* Raw primitive values in local-variable and operand-stack slots. / final long[] prims; / Object references in local-variable and operand-stack slots. / final Object[] refs; / Bytecode of the method being interpreted. / final byte[] code; class Stack { / Next free operand-stack slot in Frame.prims/refs. */ int top; static int iconst0Handler(int bci, Frame frame, Stack stack) { int slot = stack.top; frame.prims[slot] = 0; stack.top = slot + 1; return bci + 1; 这里的 bci 参数是字节码索引,即当前指令在方法字节码数组中的字节偏移量。

当 frame 和 stack 作为普通参数跨越处理器边界时,相关的快速路径汇编如下:

; regBCI: incoming bci ; regFrame: incoming frame ; regStack: incoming stack mov regPrims, QWORD PTR [regFrame + OFFSET_prims] ; load frame.prims ▶ mov regTop32, DWORD PTR [regStack + OFFSET_top] ; load stack.top test regPrims, regPrims ; is prims null? je null_prims cmp regTop32, DWORD PTR [regPrims + OFFSET_length] ; is top out of bounds? jae out_of_bounds mov QWORD PTR [regPrims + regTop * 8 + OFFSET_arrayStart], 0 ; prims[slot] = 0 inc regTop32 ; advance the stack top ▶ mov DWORD PTR [regStack + OFFSET_top], regTop32 ; write back stack.top inc regBCI32 ; advance to the next bytecode ret ; Here and below, reg*32 denotes the lower 32 bits of the corresponding register.

对于一个仅仅压入零的字节码来说,这可谓前呼后拥。例如,更新 stack.top 就需要将该字段加载到寄存器、递增、再存回。

在上一篇文章中,我们让对象以分片的方式跨越编译边界,同时保持处理器的 Java 签名不变。我们称之为参数展开,并根据原对象是否也跨越边界区分了两种形式。

在虚拟展开中,字段独立传递,不携带原对象引用。生成的 stub 围绕这些值重建对象并调用原始处理器;内联之后,逃逸分析可以消除重建的对象。

在物化展开中,原对象引用与选定的字段一同传递。处理器直接接收这些字段,同时保留对背后对象及其其他字段的访问能力。

对于 Frame,我们选择物化展开,并选取 prims、refs 和 code 作为可展开字段。对于 Stack,我们选择虚拟展开。它将 top 送去加入可展开字段,但自身留在原地。

综合这些选择,处理器的边界元组从 <bci, frame, stack> 变为 <bci, frame, frame.prims, frame.refs, frame.code, stack.top>。

借助特化的调用约定,这些值会到达处理器所需的寄存器中:

; regBCI: incoming bci ; regFrame: incoming frame ; regPrims: incoming frame.prims ; regRefs: incoming frame.refs ; regCode: incoming frame.code ; regTop: incoming stack.top ▶ test regPrims, regPrims ; is prims null? ▶ je null_prims cmp regTop32, DWORD PTR [regPrims + OFFSET_length] ; is top out of bounds? jae out_of_bounds mov regSlot32, regTop32 ; zero-extend top into regSlot mov QWORD PTR [regPrims + regSlot * 8 + OFFSET_arrayStart], 0 ; prims[slot] = 0 inc regTop32 ; advance the stack top inc regBCI32 ; advance to the next bytecode ; Threaded dispatch replaces the return with a jump to the next handler jmp tail_call_target(regCode, regBCI)

stack.top 现在到达 regTop 的低 32 位,其更新后的值留在那里供下一个处理器使用。

字段的读写流量现在消失了,但空检查和边界检查仍然存在。

Native Image 的全局数据流分析可以证明某些引用非空。然而,当这种证明不可用时,编译器必须保留空检查。

幸运的是,参数展开让我们可以将非空性指定为处理器接口的一部分。这一约定既适用于 frame 这样的直接引用参数,也适用于 frame.prims 这样的展开引用字段。

用 Graal 的术语来说,该参数会获得一个非空标记(non-null stamp),它描述了该参数可以持有的值。处理器编译可以利用该标记来消除内部的空检查。

检查仍然必须发生。只是不必在每个处理器中都发生。编译器会在分派循环中调用处理器之前以及在线程化尾调用之前插入空检查。对于像 Frame.prims 这样 final 的字段,传出的展开参数仍然是同一个非空引用。因此,编译器也可以在传出的线程化尾调用之前折叠掉空检查。

借助参数展开和非空约定,相关的线程化处理器汇编如下:

; regBCI: incoming bci ; regFrame: incoming frame (non-null)

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →