- 【译】JVM Anatomy Park #20: FPU 溢出
- 【译】JVM Anatomy Park #19: 锁省略
- 【译】 JVM Anatomy Park #13: 代间屏障
- 【译】 JVM Anatomy Park #8: 本地变量可达性
- 【译】JVM Anatomy Park #22: 安全点检查
- 【译】JVM Anatomy Park #16: 超多态虚调用
- 【译】JVM Anatomy Park #17: 信任非静态 F
- 【译】JVM Anatomy Park #5: TLABs 与
- 【译】JVM Anatomy Park #14: 常量变量
- 【译】 JVM Anatomy Park #10: String
原文地址:JVM Anatomy Park #20: FPU Spills
问题
当代码中完全没有浮点数或者向量操作的时候,我在 JVM 生成的 x86 机器码中居然也看到了 XMM 寄存器的使用。这是怎么回事?
理论
浮点运算单元(FPU)和向量单元(vector unit)普遍存在于现代 CPU 中,在许多情况下它们为 FPU 特定操作提供了寄存器。例如 Intel x86_64 中的 SSE 和 AVX 增设了 XMM、YMM 和 ZMM 寄存器,这些寄存器可以用于配合更宽的指令。
虽然非向量指令集并不与向量和非向量寄存器正交(例如,在 x86_64 中我们不能在 XMM 寄存器上使用通用的 IMUL),但是这些寄存器仍然提供了一个有趣的存储功能:我们可以在其中临时存储数据,即使这些数据不用于向量操作。[1]
关于寄存器分配。寄存器分配器的职责是,维护在特定的编译单元(例如,方法)中程序需要的所有操作数的程序表示,并且映射这些虚操作数到实际的机器寄存器 —— 为它们分配寄存器。在许多真实的程序中,在给定程序位置,虚操作数的数量会大于可用机器寄存器的数量。在那时,寄存器分配器需要将某些操作数放到寄存器之外的其它位置 —— 比如放到栈上 —— 也就是,溢出操作数。
现在在 x86_64 中有 16 个通用寄存器(并不是所有的都可用),在大部分现代的机器上还会有 16 个 AVX 寄存器。我们可以溢出到 XMM 寄存器,而不是栈上么?是的,我们可以!这有什么好处么?
实践
考虑这个简单的 JMH 测试用例。我们用一种特别的方式构建测试用例(假设 Java 具有预处理能力,为简单起见):
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class FPUSpills {
int s00, s01, s02, s03, s04, s05, s06, s07, s08, s09;
int s10, s11, s12, s13, s14, s15, s16, s17, s18, s19;
int s20, s21, s22, s23, s24;
int d00, d01, d02, d03, d04, d05, d06, d07, d08, d09;
int d10, d11, d12, d13, d14, d15, d16, d17, d18, d19;
int d20, d21, d22, d23, d24;
int sg;
volatile int vsg;
int dg;
@Benchmark
#ifdef ORDERED
public void ordered() {
#else
public void unordered() {
#endif
int v00 = s00; int v01 = s01; int v02 = s02; int v03 = s03; int v04 = s04;
int v05 = s05; int v06 = s06; int v07 = s07; int v08 = s08; int v09 = s09;
int v10 = s10; int v11 = s11; int v12 = s12; int v13 = s13; int v14 = s14;
int v15 = s15; int v16 = s16; int v17 = s17; int v18 = s18; int v19 = s19;
int v20 = s20; int v21 = s21; int v22 = s22; int v23 = s23; int v24 = s24;
#ifdef ORDERED
dg = vsg; // Confuse optimizer a little
#else
dg = sg; // Just a plain store...
#endif
d00 = v00; d01 = v01; d02 = v02; d03 = v03; d04 = v04;
d05 = v05; d06 = v06; d07 = v07; d08 = v08; d09 = v09;
d10 = v10; d11 = v11; d12 = v12; d13 = v13; d14 = v14;
d15 = v15; d16 = v16; d17 = v17; d18 = v18; d19 = v19;
d20 = v20; d21 = v21; d22 = v22; d23 = v23; d24 = v24;
}
}
测试用例一次性读写多对字段。优化器实际上不受特定程序顺序的约束。的确,这就是我们在 unordered
测试中观察到的:
Benchmark Mode Cnt Score Error Units
FPUSpills.unordered avgt 15 6.961 ± 0.002 ns/op
FPUSpills.unordered:CPI avgt 3 0.458 ± 0.024 #/op
FPUSpills.unordered:L1-dcache-loads avgt 3 28.057 ± 0.730 #/op
FPUSpills.unordered:L1-dcache-stores avgt 3 26.082 ± 1.235 #/op
FPUSpills.unordered:cycles avgt 3 26.165 ± 1.575 #/op
FPUSpills.unordered:instructions avgt 3 57.099 ± 0.971 #/op
大约有 26 个 load-store 指令对,大致对应测试用例中的 25 对读写操作。但是我们没有 25 个通用寄存器!perfasm 的输出表明,优化器合并了邻近的 load-store 指令对,所以寄存器的压力很低:
0.38% 0.28% movzbl 0x94(%rcx),%r9d
│ ...
0.25% 0.20% │ mov 0xc(%r11),%r10d ; getfield s00
0.04% 0.02% │ mov %r10d,0x70(%r8) ; putfield d00
│ ...
│ ... (transfer repeats for multiple vars) ...
│ ...
╰ je BACK
此时此刻,我们想要欺骗一下优化器,制造一点儿混淆,使得所有的加载操作在存储操作前执行完。这是 ordered
测试的场景,我们可以看到加载和存储操作是分开执行的:首先执行所有加载操作,然后执行所有存储操作。当所有加载操作完成时,寄存器的压力最高,但是此时存储操作还没有开始。即使那样,相对于 unordered
也没有明显的性能区别:
Benchmark Mode Cnt Score Error Units
FPUSpills.unordered avgt 15 6.961 ± 0.002 ns/op
FPUSpills.unordered:CPI avgt 3 0.458 ± 0.024 #/op
FPUSpills.unordered:L1-dcache-loads avgt 3 28.057 ± 0.730 #/op
FPUSpills.unordered:L1-dcache-stores avgt 3 26.082 ± 1.235 #/op
FPUSpills.unordered:cycles avgt 3 26.165 ± 1.575 #/op
FPUSpills.unordered:instructions avgt 3 57.099 ± 0.971 #/op
FPUSpills.ordered avgt 15 7.961 ± 0.008 ns/op
FPUSpills.ordered:CPI avgt 3 0.329 ± 0.026 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 29.070 ± 1.361 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 26.131 ± 2.243 #/op
FPUSpills.ordered:cycles avgt 3 30.065 ± 0.821 #/op
FPUSpills.ordered:instructions avgt 3 91.449 ± 4.839 #/op
这是因为我们设法将操作数溢出到了 XMM 寄存器,而不是栈上:
3.08% 3.79% vmovq %xmm0,%r11
│ ...
0.25% 0.20% │ mov 0xc(%r11),%r10d ; getfield s00
0.02% │ vmovd %r10d,%xmm4 ; <--- FPU SPILL
0.25% 0.20% │ mov 0x10(%r11),%r10d ; getfield s01
0.02% │ vmovd %r10d,%xmm5 ; <--- FPU SPILL
│ ...
│ ... (more reads and spills to XMM registers) ...
│ ...
0.12% 0.02% │ mov 0x60(%r10),%r13d ; getfield s21
│ ...
│ ... (more reads into registers) ...
│ ...
│ ------- READS ARE FINISHED, WRITES START ------
0.18% 0.16% │ mov %r13d,0xc4(%rdi) ; putfield d21
│ ...
│ ... (more reads from registers and putfileds)
│ ...
2.77% 3.10% │ vmovd %xmm5,%r11d : <--- FPU UNSPILL
0.02% │ mov %r11d,0x78(%rdi) ; putfield d01
2.13% 2.34% │ vmovd %xmm4,%r11d ; <--- FPU UNSPILL
0.02% │ mov %r11d,0x70(%rdi) ; putfield d00
│ ...
│ ... (more unspills and putfields)
│ ...
╰ je BACK
注意,我们对一些操作数使用通用寄存器(GPRs),但是当通用寄存器耗尽时,操作数就会溢出了。这里的“因果关系”定义不明确,因为我们似乎是首先溢出了,然后使用 GPRs,但是这只是错误的表象,因为寄存器分配器可以直接对所有寄存器进行分配。[2]
XMM 溢出的延迟看起来很小:尽管我们为溢出声明了更多指令,但是指令执行非常高效,填补了流水线的缺口:34 个附加的指令,意味着溢出了大约 17 对,但是只增加了 4 个周期。注意,4/34 = ~0.11 clk/insn 这样计算 CPI 是不正确的,这超出了当前 CPU 的能力。但是性能的改善是真实的,因为我们使用了之前没用过的执行指令。
如果没有比较的对象,那么所谓的效率就没有意义了。但是这里,我们有!我们可以用 -XX:-UseFPUForSpilling
使得 Hotspot 避免使用 FPU 溢出,这让我们可以了解使用 XMM 溢出获得了多大收益:
Benchmark Mode Cnt Score Error Units
# Default
FPUSpills.ordered avgt 15 7.961 ± 0.008 ns/op
FPUSpills.ordered:CPI avgt 3 0.329 ± 0.026 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 29.070 ± 1.361 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 26.131 ± 2.243 #/op
FPUSpills.ordered:cycles avgt 3 30.065 ± 0.821 #/op
FPUSpills.ordered:instructions avgt 3 91.449 ± 4.839 #/op
# -XX:-UseFPUForSpilling
FPUSpills.ordered avgt 15 10.976 ± 0.003 ns/op
FPUSpills.ordered:CPI avgt 3 0.455 ± 0.053 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 47.327 ± 5.113 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 41.078 ± 1.887 #/op
FPUSpills.ordered:cycles avgt 3 41.553 ± 2.641 #/op
FPUSpills.ordered:instructions avgt 3 91.264 ± 7.312 #/op
哎呀,看到每次操作增长的 load/store 计数器了吗?这些就是栈溢出:栈虽然很快,但是也是在内存中呀,因此需要访问 L1 缓存中的栈空间。此时也需要溢出大约 17 对,但是现在增加了 11 个周期。L1 缓存的吞吐量是这里的限制因素。
最后,我们可以看一下 -XX:-UseFPUForSpilling
的 perfasm 输出:
2.45% 1.21% mov 0x70(%rsp),%r11
│ ...
0.50% 0.31% │ mov 0xc(%r11),%r10d ; getfield s00
0.02% │ mov %r10d,0x10(%rsp) ; <--- stack spill!
2.04% 1.29% │ mov 0x10(%r11),%r10d ; getfield s01
│ mov %r10d,0x14(%rsp) ; <--- stack spill!
│ ...
│ ... (more reads and spills to stack) ...
│ ...
0.12% 0.19% │ mov 0x64(%r10),%ebp ; getfield s22
│ ...
│ ... (more reads into registers) ...
│ ...
│ ------- READS ARE FINISHED, WRITES START ------
3.47% 4.45% │ mov %ebp,0xc8(%rdi) ; putfield d22
│ ...
│ ... (more reads from registers and putfields)
│ ...
1.81% 2.68% │ mov 0x14(%rsp),%r10d ; <--- stack unspill
0.29% 0.13% │ mov %r10d,0x78(%rdi) ; putfield d01
2.10% 2.12% │ mov 0x10(%rsp),%r10d ; <--- stack unspill
│ mov %r10d,0x70(%rdi) ; putfield d00
│ ...
│ ... (more unspills and putfields)
│ ...
╰ je BACK
是的,栈溢出的位置与 XMM 溢出的位置类似。
观察
FPU 溢出是缓解寄存器压力的好方法。这不需要增加通用操作的可用寄存器数量,而是为溢出提供了更快的临时存储空间:因此当我们仅仅需要一些额外的溢出槽时,我们可以避免切换到基于 L1 缓存的栈上。
有时这会导致有趣的性能偏差:如果在某些关键路径上没有使用 FPU 溢出,那么我们可能会看到性能下降。例如,慢路径 GC 屏障调用(该调用会清空FPU寄存器)可能会让编译器返回使用基于栈的溢出,而不会尝试高性能的操作。
在 Hotspot 中,-XX:+UseFPUForSpilling
默认支持带有 SSE 的 x86 平台,ARMv7 和 AArch64。所以无论你是否知道这个优化,这对多大部分程序都有效。
[1] 这种技术的极端案例是使用向量寄存器做为行缓冲区!
[2] 某些寄存器分配器可能执行线性分配 —— 提高寄存器分配速度,使生成的代码更高效
网友评论