JVM常用概念之编译器黑洞
问题
JMH 如何避免微小基准测试中的不会运行的代码的消除工作?是否有隐式或显式编译器支持?
基础知识
优化编译器擅长优化简单的东西。例如,如果存在任何人都无法观察到的计算,则可以将其视为“不会运行的代码”并将其删除。
这通常是一件好事,直到你运行基准测试。在那里,你想要计算,但你不需要结果。本质上,你观察基准测试所占用的“资源”,但没有简单的方法可以与编译器争论这一点。
比如下面的测试用例,该方法中只涉及到变量x和变量y的计算,但是该方法没有返回值,也就是说,该方法是不需要执行的,具体代码如下:
int x, y;
@Benchmark
public void test_dead() {
int r = x + y;
}
编译后的汇编指令如下:
1.72% ↗ ...370: movzbl 0x94(%r9),%r10d ; load $isDone
2.06% │ ...378: mov 0x348(%r15),%r11 ; safepoint poll, part 1
27.91% │ ...37f: add $0x1,%rbp ; ops++;
28.56% │ ...383: test %eax,(%r11) ; safepoint poll, part 2
33.43% │ ...386: test %r10d,%r10d ; are we done? spin back if not.
╰ ...389: je ...370
从上述的结果来看,只有基准测试相关的汇编代码,并没有x+y相关的汇编指令。
纯 Java 黑洞
从始至今,JMH 就通过接受基准测试的结果来提供避免消除不会运行的代码代码的方法。在底层,这是通过将结果输入到Blackhole中来实现的,在某些情况下也可以直接使用。
简而言之,Blackhole 必须对传入的参数实现一个副作用:假装它被使用。Blackhole实现说明描述了 Blackhole 实现者在尝试与编译器合作时必须处理的问题。高效实现它是近 JVM 工程的一项很好的实践方向。
无论如何,所有这些混乱对于 JMH 用户来说都是隐藏的,因此他们可以这样做:
int x, y;
@Benchmark
public int test_return() {
return x + y;
}
但是,如果你查看生成的代码,你会看到计算和Blackhole代码都在那里,具体的汇编代码如下:
main loop:
2.09% ↗ ...e32: mov 0x40(%rsp),%r10 ; load $this
7.46% │ ...e37: mov 0x10(%r10),%edx ; load $this.x
0.64% │ ...e3b: add 0xc(%r10),%edx ; add $this.y
2.11% │ ...e3f: mov 0x38(%rsp),%rsi ; call Blackhole.consume
1.74% │ ...e44: data16 xchg %ax,%ax
6.52% │ ...e47: callq ...a80
18.37% │ ...e4c: mov (%rsp),%r10
1.50% │ ...e50: movzbl 0x94(%r10),%r11d ; load $isDone
2.85% │ ...e58: mov 0x348(%r15),%r10 ; safepoint poll, part 1
6.74% │ ...e5f: add $0x1,%rbp ; ops++
0.62% │ ...e63: test %eax,(%r10) ; safepoint poll, part 2
0.66% │ ...e66: test %r11d,%r11d ; are we done? spin back if not.
╰ ...e69: je ...e32
Blackhole.consume:
2.34% ...040: mov %eax,-0x14000(%rsp) ; too
9.14% ...047: push %rbp ; lazy
0.64% ...048: sub $0x20,%rsp ; to
3.38% ...04c: mov %edx,%r11d ; cross-reference
6.66% ...04f: xor 0xb0(%rsi),%r11d ; this
0.68% ...056: mov %edx,%r8d ; with
1.76% ...059: xor 0xb8(%rsi),%r8d ; the
1.62% ...060: cmp %r8d,%r11d ; actual
╭ ...063: je ...078 ; Blackhole
7.22% │ ...065: add $0x20,%rsp ; code
0.35% │ ...069: pop %rbp
2.01% │ ...06a: cmp 0x340(%r15),%rsp
│ ...071: ja ...094
8.53% │ ...077: retq
↘ ...078: mov %rsi,%rbp
毫不奇怪, Blackhole成本在如此小的基准测试中占据主导地位。使用-prof perfnorm ,我们可以看到它有多糟糕:
Benchmark Mode Cnt Score Error Units
XplusY.test_return avgt 25 3.288 ± 0.032 ns/op
XplusY.test_return:L1-dcache-loads avgt 5 13.092 ± 0.487 #/op
XplusY.test_return:L1-dcache-stores avgt 5 3.031 ± 0.076 #/op
XplusY.test_return:branches avgt 5 5.031 ± 0.089 #/op
XplusY.test_return:cycles avgt 5 8.781 ± 0.351 #/op
XplusY.test_return:instructions avgt 5 27.162 ± 0.489 #/op
也就是说,我们的“有效负载”只有 2 条指令,但整个基准测试还需要另外 25 条指令!是的,现代 CPU 可以在大约 9 个周期内执行这一大堆指令,但这仍然太过繁重。更糟糕的是,调用代码和相关的堆栈管理引入了存储。
基准测试本身大约需要 3.2 ns/op,这为我们能够可靠测量的效果设置了下限。
编译器黑洞
我们可以要求编译器提供更直接的配合,使用编译器黑洞。这些是在 OpenJDK 17 中通过JDK-8259316实现的,并计划将其反向移植到 11u。编译器黑洞指示编译器在优化阶段携带所有参数,然后在发出生成的代码时最终丢弃它们。然后,只要硬件本身不给我们带来意外,我们就应该没问题。
它们应该对 JMH 用户透明地工作,但由于整个过程尚处于实验阶段,因此目前 JMH 用户需要使用-Djmh.blackhole.mode=COMPILER选择加入编译器黑洞,然后检查生成的代码是否正确。 事实上,在我们的基准测试中使用编译器黑洞,我们可以看到计算仍然存在,并且不再有Blackhole调用!
8.95% ↗ ...c00: mov 0x10(%r11),%r10d ; load $this.x
0.36% │ ...c04: add 0xc(%r11),%r10d ; add $this.y
│ ; (AND COMPILER BLACKHOLE IT)
0.94% │ ...c08: movzbl 0x94(%r14),%r8d ; load $isDone
26.76% │ ...c10: mov 0x348(%r15),%r10 ; safepoint poll, part 1
8.42% │ ...c17: add $0x1,%rbp ; ops++
0.43% │ ...c1b: test %eax,(%r10) ; safepoint poll, part 2
46.96% │ ...c1e: test %r8d,%r8d ; are we done? spin back if not.
0.02% ╰ ...c21: je ...c00
除了在扩展的反汇编注释中,你甚至无法在任何地方看到黑洞代码,但它的效果就在那里:计算被保留了下来。 -prof perfnorm所展现的效果会更加明显:
Benchmark Mode Cnt Score Error Units
XplusY.test_return avgt 25 0.963 ± 0.042 ns/op
XplusY.test_return:L1-dcache-loads avgt 5 5.029 ± 0.170 #/op
XplusY.test_return:L1-dcache-stores avgt 5 0.001 ± 0.002 #/op
XplusY.test_return:branches avgt 5 1.006 ± 0.019 #/op
XplusY.test_return:cycles avgt 5 2.569 ± 0.108 #/op
XplusY.test_return:instructions avgt 5 8.043 ± 0.182 #/op
不再有存储,只有 6 条附加指令用于承载基础设施。整个基准测试能够在不到 3 个周期和不到 1 纳秒的时间内完成,这涉及 5 次 L1 访问,其中 3 次是基础设施访问。
这也使得显式使用Blackhole更加方便,例如在执行循环时:
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class CPI_Floor {
@Param({"1000000"})
private int count;
@Benchmark
public void test(Blackhole bh) {
for (int c = 0; c < count; c += 10000) {
for (int k = 0; k < 10000; k++) {
int v = k + k + k;
bh.consume(v);
}
}
}
}
在 TR 3970X 上,这达到了 CPI 下限或 ~0.16 clks/insn 或 IPC 上限 ~6 insn/clk!事实上,看起来整个“k”内循环恰好在一个周期内执行!
Benchmark (count) Mode Score Error Units
CPI_Floor.test 1000000 avgt 273422.337 ± 12722.427 ns/op
CPI_Floor.test:CPI 1000000 avgt 0.169 clks/insn
CPI_Floor.test:IPC 1000000 avgt 5.907 insns/clk
CPI_Floor.test:branches 1000000 avgt 1003135.103 #/op
CPI_Floor.test:cycles 1000000 avgt 1022821.963 #/op
CPI_Floor.test:instructions 1000000 avgt 6042142.469 #/op
总结
编译器黑洞非常适合低级性能调查。尝试使用它们,检查它们是否按您的要求运行,展示成功和失败案例,并希望所有这些最终都会在 JMH 中出现新的默认黑洞模式。