Java JVM能够进行简单的递归调用预计算吗?

Java JVM能够进行简单的递归调用预计算吗?,java,performance,jvm,microbenchmark,Java,Performance,Jvm,Microbenchmark,不久前,我运行了两种不同软件乘法算法的rust基准测试:普通递归乘法和俄罗斯农民乘法 令我惊讶的是,编译器能够分析琐碎的递归,用结果直接替换对方法的调用(例如调用mul0(4,8)->32) 为了查看JVM是否能够执行相同的优化,我通过JMH测量了下面的Java实现。然而,俄罗斯农民算法更快,而且VM似乎没有执行任何类似的优化 JVM中是否有类似的优化技术(用预计算结果替换递归调用),或者JVM本身出于某种原因没有这样做 我知道这是依赖于虚拟机的,并且可能会发生变化,所以我更感兴趣的是阻碍虚拟机

不久前,我运行了两种不同软件乘法算法的rust基准测试:普通递归乘法和俄罗斯农民乘法

令我惊讶的是,编译器能够分析琐碎的递归,用结果直接替换对方法的调用(例如调用
mul0(4,8)->32

为了查看JVM是否能够执行相同的优化,我通过JMH测量了下面的Java实现。然而,俄罗斯农民算法更快,而且VM似乎没有执行任何类似的优化

JVM中是否有类似的优化技术(用预计算结果替换递归调用),或者JVM本身出于某种原因没有这样做

我知道这是依赖于虚拟机的,并且可能会发生变化,所以我更感兴趣的是阻碍虚拟机实现者将这种优化纳入其虚拟机的一般障碍

代码片段:

@Warmup(iterations = 10)
@Fork(value = 2)
@State(Scope.Benchmark)
public class MyBenchmark {

    private int F1 = 542;
    private int F2 = 323;

    public final static int mul0(int a, int b) {
        if (a == 1) {
            return b;
        }
        return mul0(a - 1, b) + b;
    }

    //O(log n)
    public final static int mul2(int a, int b) {
        if (a == 1) {
            return b;
        }

        int sum = ((a & 1) == 1) ? b : 0;

        return mul2(a / 2, b + b) + sum;
    }

    @Benchmark
    public void test0() {
        mul0(F1, F2);
    }

    @Benchmark
    public void test2() {
        mul2(F1, F2);
    }

}
结果:

Result: 13852692,903 ▒(99.9%) 532102,125 ops/s [Average]
  Statistics: (min, avg, max) = (9899651,068, 13852692,903, 15356453,576), stdev = 945811,061
  Confidence interval (99.9%): [13320590,778, 14384795,028]


# Run complete. Total time: 00:02:22

Benchmark                   Mode  Samples         Score  Score error  Units
d.s.m.MyBenchmark.test0    thrpt       40   1453817,627    68528,256  ops/s
d.s.m.MyBenchmark.test2    thrpt       40  13852692,903   532102,125  ops/s

让我们分析一下这种优化对JVM意味着什么

可能吗?

首先,假设JVM看到一个调用
mul0(4,8)
(当然,是用字节码表示的,但是在讨论中,让我们继续使用可读性更强的Java源代码语法)。让我们假设这个代码块执行得足够频繁,所以热点引擎决定它值得优化

现在,引擎需要看到
mul0()
方法是一个纯函数,当使用相同的参数调用时总是返回相同的结果。这意味着遍历从mul0()方法内部可以访问的所有指令,并检查它们除了参数之外没有访问任何变量。我认为Hotspot引擎也有类似的推理能力,所以这个引擎也应该是可行的

然后,引擎只需再次运行递归方法来查找结果,并用加载整数32替换
mul0(4,8)
调用

值得付出痛苦吗?

我描述的推理仅适用于固定参数情况,如
mul0(4,8)
中所述。它不适用于变量
mul0(x,y)
调用

您发现Java编译器已经处理了常量args的情况(至少有时是这样),所以在JVM中再次处理它是没有用的

这种优化只会对那些反复使用相同参数进行昂贵计算的程序有所帮助。因此,它只会帮助那些甚至不知道编写高效代码的基础知识的开发人员,更糟糕的是,它不会教育他们提高技能

为什么它在Java编译器中有用?

如果编译器检测到一个表达式有一个常量结果,它可以在编译时计算该结果,因此即使语句的第一次执行也是在“零时间”运行的。因此,在这里,每次程序运行时都要投入一点编译时间来获得更好的性能。

简短回答 HotSpot JVM能够进行这样的优化,但是默认的JVM选项阻止这样做

长话短说 首先,需要对基准进行轻微修正,以查看效果

  • 根据设计,
    @State
    类的字段在每次迭代中都会被重新读取。JVM不知道它们是常量,所以它不能将它们进行常量折叠。将
    F1
    F2
    设为最终值,使其保持不变,并允许进一步优化
  • 基准测试方法应该通过调用
    Blackhole.consume
    或简单地从方法返回一个值来消耗计算结果

    private final int F1 = 542;
    private final int F2 = 323;
    
    public final static int mul0(int a, int b) {
        if (a == 1) {
            return b;
        }
        return mul0(a - 1, b) + b;
    }
    
    //O(log n)
    public final static int mul2(int a, int b) {
        if (a == 1) {
            return b;
        }
    
        int sum = ((a & 1) == 1) ? b : 0;
    
        return mul2(a / 2, b + b) + sum;
    }
    
    @Benchmark
    public int test0() {
        return mul0(F1, F2);
    }
    
    @Benchmark
    public int test2() {
        return mul2(F1, F2);
    }
    
  • 现在HotSpot可以内联方法调用并执行常量折叠。但是,默认情况下,递归方法的内联仅限于一个级别。我们可以使用以下选项覆盖此选项:

    -XX:MaxInlineLevel=20 -XX:MaxRecursiveInlineLevel=20
    
    现在
    test2
    变得非常快,因为它执行的方法调用明显少于20个:

    Benchmark               Mode  Cnt    Score    Error  Units
    MyBenchmark.test0       avgt    5  675,763 ± 16,422  ns/op
    MyBenchmark.test2       avgt    5    5,320 ±  0,274  ns/op
    
    使用
    -prof perfasm
    查看生成的汇编代码,我们可以验证
    test2
    是否返回预计算值:

    0x00000000038e5960: mov    %r10,0x20(%rsp)
    0x00000000038e5965: mov    0x58(%rsp),%rdx
    0x00000000038e596a: mov    $0x2abda,%r8d        <<<<
    0x00000000038e5970: data32 xchg %ax,%ax
    0x00000000038e5973: callq  0x00000000037061a0  ;*invokevirtual consume
    
    0x00000000038e5960:mov%r10,0x20(%rsp)
    0x00000000038e5965:mov 0x58(%rsp),%rdx
    
    0x00000000038e596a:mov$0x2abda,%r8d依赖于JVM,因为它依赖于JIT编译器。你对什么感兴趣?它可能还依赖于平台。目前在windows上运行JDK8 64位。但我更感兴趣的是为什么还没有实现它,因为它看起来是一件相对容易的事情(但我几乎没有编写编译器的经验)。为什么这会依赖于平台?我用JMH和Java 9测量两种算法的性能完全相同。@Jornverne cool会在时间允许的情况下用JDK9试试。我在问题中包含了我的结果。好吧,我只是用你的
    mul0(4,8)->32
    示例进行测试。较大的数字对于内联来说不那么简单。Afaik需要更好的持续传播,这是一个正在进行的项目:和(可能还有其他)