有没有JVM的JIT编译器生成使用向量化浮点指令的代码?

100

假设我的Java程序的瓶颈确实是一些密集循环,用于计算一堆向量点积。是的,我进行了分析,这是瓶颈,是的,它很显著,是的,算法就是这样,是的,我运行了Proguard来优化字节码,等等。

这项工作本质上是点积。也就是说,我有两个float [50],我需要计算成对乘积的总和。我知道处理器指令集存在于快速批量执行此类操作的情况下,例如SSE或MMX。

是的,我可能可以通过编写JNI中的一些本机代码来访问它们。JNI调用结果非常昂贵。

我知道您无法保证JIT将编译还是不编译代码。是否有人听说过JIT生成使用这些指令的代码?如果是,Java代码中是否有任何帮助使其以这种方式编译的内容?

可能是“否”;值得询问。


4
可能最简单的方法是获取最现代的JIT并使用-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation输出生成的汇编代码。您需要一个运行可向量化方法足够次数使其“热”的程序。 - Louis Wasserman
1
或者查看源代码。http://download.java.net/openjdk/jdk7/ - BillRobertson42
1
即将推出:在接近您的jdk中,http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2012-July/008123.html - Jonathan S. Fisher
3
根据这篇博客,实际上,如果使用得当,JNI可以非常快。 - ziggystar
2
这里可以找到相关的博客文章:http://psy-lob-saw.blogspot.com/2015/04/on-arraysfill-intrinsics-superword-and.html,其一般信息是向量化可以发生,并且确实发生了。除了针对特定情况进行向量化(Arrays.fill()/equals(char[])/arrayCopy)之外,JVM还使用超级字级并行化进行自动向量化。相关代码在superword.cpp中,基于此的论文在这里:http://groups.csail.mit.edu/cag/slp/SLP-PLDI-2000.pdf。 - Nitsan Wakart
显示剩余3条评论
9个回答

46

所以,基本上你希望你的代码运行得更快。JNI是答案。我知道你说它对你不起作用,但让我向你展示你错了。

这是Dot.java

import java.nio.FloatBuffer;
import org.bytedeco.javacpp.*;
import org.bytedeco.javacpp.annotation.*;

@Platform(include = "Dot.h", compiler = "fastfpu")
public class Dot {
    static { Loader.load(); }

    static float[] a = new float[50], b = new float[50];
    static float dot() {
        float sum = 0;
        for (int i = 0; i < 50; i++) {
            sum += a[i]*b[i];
        }
        return sum;
    }
    static native @MemberGetter FloatPointer ac();
    static native @MemberGetter FloatPointer bc();
    static native @NoException float dotc();

    public static void main(String[] args) {
        FloatBuffer ab = ac().capacity(50).asBuffer();
        FloatBuffer bb = bc().capacity(50).asBuffer();

        for (int i = 0; i < 10000000; i++) {
            a[i%50] = b[i%50] = dot();
            float sum = dotc();
            ab.put(i%50, sum);
            bb.put(i%50, sum);
        }
        long t1 = System.nanoTime();
        for (int i = 0; i < 10000000; i++) {
            a[i%50] = b[i%50] = dot();
        }
        long t2 = System.nanoTime();
        for (int i = 0; i < 10000000; i++) {
            float sum = dotc();
            ab.put(i%50, sum);
            bb.put(i%50, sum);
        }
        long t3 = System.nanoTime();
        System.out.println("dot(): " + (t2 - t1)/10000000 + " ns");
        System.out.println("dotc(): "  + (t3 - t2)/10000000 + " ns");
    }
}

还有Dot.h

float ac[50], bc[50];

inline float dotc() {
    float sum = 0;
    for (int i = 0; i < 50; i++) {
        sum += ac[i]*bc[i];
    }
    return sum;
}

我们可以使用以下命令,借助于 JavaCPP 进行编译和运行:

$ java -jar javacpp.jar Dot.java -exec

使用 Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz、Fedora 30、GCC 9.1.1 和 OpenJDK 8 或 11,我得到了以下输出:

dot(): 39 ns
dotc(): 16 ns

或大约快2.4倍。我们需要使用直接NIO缓冲区而不是数组,但是 HotSpot可以像数组一样快速访问直接NIO缓冲区。另一方面,在这种情况下,手动展开循环并不能提供可衡量的性能提升。


3
你使用了OpenJDK还是Oracle HotSpot?与普遍的观点不同,它们并不相同。 - Jonathan S. Fisher
1
那个循环很可能存在一个连锁循环依赖。通过将循环展开两次或更多次,您可以进一步提高速度。 - user2088790
在你的代码中,你没有使用SSE或MMX或NEON指令。在这种情况下,即使针对如此小的向量,使用JNI也可能更为有利。对于较大的向量,JNI是首选。 - Oliv
3
@Oliv使用SSE向量化代码,但对于这么小的数据,JNI调用开销太大了。我会将其翻译为:“@Oliv使用SSE进行代码向量化,但对于这样小的数据,JNI调用的开销太大了。” - Samuel Audet
2
在我的A6-7310上使用JDK 13,我得到的结果是:dot():69 ns / dotc():95 ns。Java胜利了! - Stefan Reich
显示剩余16条评论

42

为了应对这里其他人提出的一些怀疑,我建议任何想要向自己或他人证明的人使用以下方法:

  • 创建一个JMH项目
  • 编写一个小的可向量化的数学片段。
  • 在-XX:-UseSuperWord和-XX:+UseSuperWord(默认)之间切换运行基准测试
  • 如果没有观察到性能差异,则您的代码可能没有被向量化
  • 为了确保,请运行基准测试以使其打印出汇编。在Linux上,您可以使用perfasm分析器('-prof perfasm')查看并查看是否生成了您预期的指令。

例子:

@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE) //makes looking at assembly easier
public void inc() {
    for (int i=0;i<a.length;i++)
        a[i]++;// a is an int[], I benchmarked with size 32K
}

在最近的Haswell笔记本电脑上,使用和不使用标志的结果(使用Oracle JDK 8u60): -XX:+ UseSuperWord:475.073±44.579 ns / op(每个操作的纳秒) -XX:-UseSuperWord:3376.364±233.211 ns / op

热循环的程序集有点难以格式化并放在这里,但是这里是一个片段(hsdis.so无法格式化一些AVX2向量指令,因此我使用了-XX:UseAVX=1):-XX:+ UseSuperWord(带“-prof perfasm:intelSyntax=true”)

  9.15%   10.90%  │││ │↗    0x00007fc09d1ece60: vmovdqu xmm1,XMMWORD PTR [r10+r9*4+0x18]
 10.63%    9.78%  │││ ││    0x00007fc09d1ece67: vpaddd xmm1,xmm1,xmm0
 12.47%   12.67%  │││ ││    0x00007fc09d1ece6b: movsxd r11,r9d
  8.54%    7.82%  │││ ││    0x00007fc09d1ece6e: vmovdqu xmm2,XMMWORD PTR [r10+r11*4+0x28]
                  │││ ││                                                  ;*iaload
                  │││ ││                                                  ; - psy.lob.saw.VectorMath::inc@17 (line 45)
 10.68%   10.36%  │││ ││    0x00007fc09d1ece75: vmovdqu XMMWORD PTR [r10+r9*4+0x18],xmm1
 10.65%   10.44%  │││ ││    0x00007fc09d1ece7c: vpaddd xmm1,xmm2,xmm0
 10.11%   11.94%  │││ ││    0x00007fc09d1ece80: vmovdqu XMMWORD PTR [r10+r11*4+0x28],xmm1
                  │││ ││                                                  ;*iastore
                  │││ ││                                                  ; - psy.lob.saw.VectorMath::inc@20 (line 45)
 11.19%   12.65%  │││ ││    0x00007fc09d1ece87: add    r9d,0x8            ;*iinc
                  │││ ││                                                  ; - psy.lob.saw.VectorMath::inc@21 (line 44)
  8.38%    9.50%  │││ ││    0x00007fc09d1ece8b: cmp    r9d,ecx
                  │││ │╰    0x00007fc09d1ece8e: jl     0x00007fc09d1ece60  ;*if_icmpge

玩得开心,攻陷城堡!


1
从同一篇论文中:“JIT反汇编输出表明,它在调用最优SIMD指令及其调度方面实际上并不那么高效。通过快速搜索JVM JIT编译器(Hotspot)源代码,可以发现这是由于缺少打包的SIMD指令代码所致。” SSE寄存器正在标量模式下使用。 - Aleksandr Dubinsky
1
@AleksandrDubinsky 有些情况已经被涵盖,有些则没有。您是否有具体的案例感兴趣? - Nitsan Wakart
2
让我们换个角度来提问,JVM是否会自动向量化任何算术操作?你能提供一个例子吗?最近我确实不得不将一个循环拿出来,使用内嵌函数进行重写。然而,与其寄希望于自动向量化,我更希望看到对显式向量化/内嵌函数的支持(类似于http://www.agner.org/optimize/vectorclass.pdf)。更好的办法是为Aparapi编写一个良好的Java后端(尽管该项目的领导层有些错误的目标)。你在JVM上工作吗? - Aleksandr Dubinsky
1
@AleksandrDubinsky,希望这个更详细的回答能够帮到你。如果还有疑问,也许可以通过电子邮件联系。另外请注意,“使用内置函数重写”意味着您更改了JVM代码以添加新的内置函数,这是您的意思吗?我猜您是想通过JNI调用本地实现来替换Java代码。 - Nitsan Wakart
1
谢谢。这应该是官方答案了。我认为你应该删除对论文的引用,因为它已经过时,不能展示向量化。 - Aleksandr Dubinsky
显示剩余3条评论

26
在Java 7u40及以后的HotSpot版本中,服务器编译器提供了自动向量化的支持。根据JDK-6340864。
然而,目前看来这似乎只适用于"简单循环"。例如,目前还无法对数组进行向量化累加。参见JDK-7192383。

向量化在JDK6中也存在于某些情况下,尽管目标SIMD指令集不如广泛。 - Nitsan Wakart
3
由于英特尔的贡献,Hotspot编译器关于向量化的支持在最近得到了很大的改进(截至2017年6月)。性能方面,尚未发布的jdk9(b163及更高版本)由于bug修复,启用了AVX2,目前领先于jdk8。要使自动向量化起作用,循环必须满足一些约束条件,例如使用int计数器、常数计数器增量、一个终止条件和不变量变量、循环体没有方法调用,以及不能手动展开循环等。详细信息请参见:http://cr.openjdk.java.net/~vlivanov/talks/2017_Vectorization_in_HotSpot_JVM.pdf。 - Vedran
目前(截至2017年6月),矢量化融合多重加法(FMA)支持看起来并不好:它要么是矢量化,要么是标量FMA。然而,甲骨文公司显然刚刚接受了英特尔对HotSpot的贡献,使得FMA矢量化使用AVX-512成为可能。对于自动矢量化的粉丝和那些有幸拥有AVX-512硬件的人来说,这可能会在下一个jdk9 EA构建中出现(超过b175)。 - Vedran
2
一个小型基准测试,通过使用AVX2指令的循环矢量化,在整数上展示了4倍加速:http://prestodb.rocks/code/simd/ - Vedran
FMA不需要AVX-512。HotSpot将使用FMA3,它在Haswell以后的AVX2 CPU上可用。它没有出现在Java 9中,但随着Oracle计划每6个月发布一次Java,我们可能很快就能使用它了。 - Aleksandr Dubinsky
显示剩余2条评论

6
这是一篇关于我的朋友用Java和SIMD指令进行实验的好文章: http://prestodb.rocks/code/simd/ 总体来说,你可以期望JIT在1.8中使用一些SSE操作(在1.9中会有更多)。但是你不应该期望太多,并且需要小心。

1
如果您能总结一下您链接的文章的一些关键见解会很有帮助。 - Aleksandr Dubinsky

4

请查看Java和JNI在计算微内核最佳实现方面的性能比较。他们表明,Java HotSpot VM服务器编译器支持使用超字级并行性进行自动向量化,但仅限于简单的循环内部并行性。本文还将为您提供一些指导,以确定您的数据大小是否足够大,以证明使用JNI路线是有必要的。


4
您可以编写OpenCL内核来进行计算,并从Java中运行它 http://www.jocl.org/
代码可以在CPU和/或GPU上运行,OpenCL语言还支持向量类型,因此您应该能够明确地利用例如SSE3 / 4指令。

3

我猜你在了解netlib-java之前写下了这个问题;-) 它提供了你所需的本地API,具有机器优化实现,并且由于内存固定而不会在本地边界上产生任何成本。


1
是的,很久以前了。我更希望听到这是自动转换为矢量化指令的消息。但显然手动实现也不是很难。 - Sean Owen

3

Java 16引入了向量API (JEP 417, JEP 414, JEP 338)。目前它处于“孵化”阶段(即测试版),但任何人都可以使用它。它可能会在Java 19或20中成为GA版本。

虽然有些冗长,但旨在可靠且可移植。

以下代码可以重写:

void scalarComputation(float[] a, float[] b, float[] c) {
   assert a.length == b.length && b.length == c.length;
   for (int i = 0; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
   }
}

使用向量API:

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

void vectorComputation(float[] a, float[] b, float[] c) {
    assert a.length == b.length && b.length == c.length;
    int i = 0;
    int upperBound = SPECIES.loopBound(a.length);
    for (; i < upperBound; i += SPECIES.length()) {
        // FloatVector va, vb, vc;
        var va = FloatVector.fromArray(SPECIES, a, i);
        var vb = FloatVector.fromArray(SPECIES, b, i);
        var vc = va.mul(va)
                   .add(vb.mul(vb))
                   .neg();
        vc.intoArray(c, i);
    }
    for (; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
    }
}

较新版本的构建(如Java 18)正在尝试使用谓词指令来消除最后一个循环,但据说对其的支持还不稳定。


-6

我不相信大多数或任何虚拟机都足够聪明以进行此类优化。公平地说,大多数优化要简单得多,例如在乘幂为2时进行移位而非乘法。Mono项目引入了自己的矢量和其他具有本地支持的方法以帮助提高性能。


3
目前,没有任何一种Java热点编译器可以实现这一点,但它并不比它们已经实现的功能更难。它们使用SIMD指令一次性复制多个数组值。你只需要编写一些更多的模式匹配和代码生成代码,在展开循环后,这相当容易。我认为Sun公司的人们变得有些懒惰了,但看起来现在Oracle公司将会实现它(太好了,Vladimir!这应该会极大地帮助我们的代码!):http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2012-January/007010.html - Christopher Manning

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接