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