vlambda博客
学习文章列表

为什么 JVM x86 生成的机器代码有 XMM 寄存器?

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华

shipilev.net/jvm/anatomy-quarks/19-lock-elision/


1. 写在前面


“JVM 解剖公园”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。


Aleksey Shipilёv,JVM 性能极客

推特 @shipilev

问题、评论、建议发送到 [email protected]"">[email protected]


2. 问题


代码中没有浮点或矢量操作,为什么在 JVM x86 平台生成的机器代码中会看到 XMM 寄存器?


3. 理论


FPU 和矢量单元在现代 CPU 中无处不在。通常,它们会为 FPU 特定操作提供了备用寄存器。例如,英特尔 x86_64 平台的 SSE 和 AVX 扩展包含了一组丰富的 XMM、YMM 和 ZMM 寄存器供指令操作。


虽然非矢量指令集与矢量、非矢量寄存器通常不会正交,比如不能在 x86_64 上对 XMM 寄存器执行通用 IMUL,但是这些寄存器仍然提供了一种存储选项。即使不用于矢量计算,也可以在这些寄存器中存储数据。


(1) 最极端的情况是把矢量寄存器当缓冲用。


寄存器分配器的任务是在一个特定的编译单元(比如方法)中获取程序需要的所有操作数,并为它们分配寄存器——映射到机器实际寄存器。真实程序中,需要的操作数大于机器中可用的寄存器数目。这时寄存器分配器必须把一些操作数放到寄存器以外的某个地方(比如堆栈),也就是说会发生操作数溢出。


x86_64 上有16个通用寄存器(并非每个寄存器都可用)。目前,大多数机器还有16个 AVX 寄存器。发生溢出时,可以不存储到堆栈而存储到 XMM 寄存器中吗?答案是可以。这么做会带来什么好处?


4. 实验


看看下面这个简单的 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; // 给 optimizer 制造点麻烦
#else
dg = sg; // 只做常规存储
#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 ; 读取字段 s00
0.04% 0.02% │ mov %r10d,0x70(%r8) ; 存储字段 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 ; 读取字段 s00
0.02% │ vmovd %r10d,%xmm4 ; <--- FPU 溢出
0.25% 0.20% │ mov 0x10(%r11),%r10d ; 读取字段 s01
0.02% │ vmovd %r10d,%xmm5 ; <--- FPU 溢出
│ ...
│ ... (读取更多字段和 XMM 溢出) ...
│ ...
0.12% 0.02% │ mov 0x60(%r10),%r13d ; 读取字段 s21
│ ...
│ ... (读取到寄存器) ...
│ ...
│ ------- 读取完成, 开始写操作 ------
0.18% 0.16% │ mov %r13d,0xc4(%rdi) ; 存储字段 d21
│ ...
│ ... (读寄存器并存储字段)
│ ...
2.77% 3.10% │ vmovd %xmm5,%r11d : <--- FPU 取消溢出
0.02% │ mov %r11d,0x78(%rdi) ; 存储字段 d01
2.13% 2.34% │ vmovd %xmm4,%r11d ; <--- FPU 取消溢出
0.02% │ mov %r11d,0x70(%rdi) ; 存储字段 d00
│ ...
│ ... (取消溢出并存储字段)
│ ...
╰ je BACK


请注意:这里的确对某些操作数使用了通用寄存器(GPR),但是当所有寄存器被用完时会发生溢出。这里对时机的描述并不确切。看起来先发生了溢出,然后使用 GPR。然而这是一个假象,因为寄存器分配器是在全局进行分配。


(2) 一些寄存器分配器实际执行的是线性分配,提高了 regalloc 的速度与生成代码的效率。


XMM 溢出延迟似乎是最小的:尽管溢出需要更多指令,但它们的执行效率很高能够有效弥补流水线的缺陷。通过34条额外指令,大约17条溢出指令对,实际只要求4个额外周期。请注意,按照 4/34 = ~0.11 时钟/指令 计算 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 ; 读取字段 s00
0.02% │ mov %r10d,0x10(%rsp) ; <--- 堆栈溢出!
2.04% 1.29% │ mov 0x10(%r11),%r10d ; 读取字段 s01
│ mov %r10d,0x14(%rsp) ; <--- 堆栈溢出!
│ ...
│ ... (读取其它字段和堆栈溢出) ...
│ ...
0.12% 0.19% │ mov 0x64(%r10),%ebp ; 读取字段 s22
│ ...
│ ... (more reads into registers) ...
│ ...
│ ------- 读取完成, 开始写操作 ------
3.47% 4.45% │ mov %ebp,0xc8(%rdi) ; 存储字段 d22
│ ...
│ ... (读取更多寄存器和存储字段)
│ ...
1.81% 2.68% │ mov 0x14(%rsp),%r10d ; <--- 取消堆栈溢出
0.29% 0.13% │ mov %r10d,0x78(%rdi) ; 存储字段 d01
2.10% 2.12% │ mov 0x10(%rsp),%r10d ; <--- 取消堆栈溢出
│ mov %r10d,0x70(%rdi) ; 存储字段 d00
│ ...
│ ... (取消其它溢出和存储字段)
│ ...
╰ je BACK


的确,在堆栈溢出发生的地方也可以看到 XMM 溢出。


5. 观察


FPU 溢出是缓解寄存器压力的一种好办法。虽然不增加通用寄存器寄存器数量,但确实在溢出时提供了更快的临时存储。在仅需要几个额外的溢出存储时,可以避免转存到 L1 缓存支持的堆栈。


这为什么有时会出现奇怪的性能差异:如果在一些关键路径上没有用到 FPU 溢出,很可能会看到性能下降。例如,引入一个 slow-path GC 屏障,假定会清除 FPU 寄存器,可能会让编译器回退到堆栈溢出,并不去尝试其它优化。


对支持 SSE 的 x86 平台、ARMv7 和 AArch64,Hotspot 默认启用 -XX:+UseFPUForSpilling因此,无论是否知道这个技巧,大多数程序都能从中受益。


推荐阅读

(点击标题可跳转阅读)





看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️