为什么在JVM x86平台生成的机器代码中会看到XMM寄存器?

电子说

1.3w人已加入

描述

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。因此,无论是否知道这个技巧,大多数程序都能从中受益。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分