1

从Java Math底层实现看Arm与x86的差异

 1 year ago
source link: https://yikun.github.io/2020/04/10/%E4%BB%8EJava-Math%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0%E7%9C%8BArm%E4%B8%8Ex86%E7%9A%84%E5%B7%AE%E5%BC%82/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从Java Math底层实现看Arm与x86的差异

Apr 10 2020

最近在进行ARM切换的过程中发现了很多因为Java Math库在不同的平台上的精度不同导致用例失败,我们以Math.log为例,做一下简单的分析。下面是一个简单的计算log(3)的示例:

public class Hello {
public static void main(String[] args) {
System.out.println("Math.log(3): " + Math.log(3));
System.out.println("StrictMath.log(3): " + StrictMath.log(3));
}
}

我们发现,在x86下,Math的结果为1.0986122886681098

# on x86
$ java Hello
Math.log(3): 1.0986122886681098
StrictMath.log(3): 1.0986122886681096

而aarch64的结果为1.0986122886681096

# on aarch64
$ java Hello
Math.log(3): 1.0986122886681096
StrictMath.log(3): 1.0986122886681096

而在Java 8的官方文档中,对此有明确说明:

Unlike some of the numeric methods of class StrictMath, all implementations of the equivalent functions of class Math are not defined to return the bit-for-bit same results. This relaxation permits better-performing implementations where strict reproducibility is not required.

因此,结论是:Math的结果有可能是不精确的,如果结果对精度有苛求,那么请使用StrictMath

在此,我们留下2个疑问:

  1. 为什么说Math的实现不是the bit-for-bit same results
  2. Math是怎么实现在各个架构下better-performing implementations的?

2. 深度探索一下Math的实现

为了能够更清晰的看到StrictMath的实现,我们深入的看了下JDK的实现。

2.1 Math和StrictMath的基本实现

我们从Math.log和StrictMath.log的实现为例,进行深入学习:

  1. Math.log的代码表面上很简单,就是直接调用StrictMath.log。

    public static double log(double a) {
    return StrictMath.log(a); // default impl. delegates to StrictMath
    }
  2. StrictMath的代码,会调用StrictMath.c中的方法,最终会调用fdlibm的e_log.c的实现。

总体的实现和下图类似:
image

对于StrictMath来说,没有什么黑科技,最终的实现就是e_log.c的ieee754标准实现,是通过C语言实现的,所以在各个平台的表现是一样的,整个流程如图中蓝色部分。感兴趣的同学可以看e_log.c的源码实现即可。

2.2 Math的黑科技

回到我们最初的起点,再加上一个问题:

  1. 为什么说Math的实现不是the bit-for-bit same results
  2. Math是怎么实现在各个架构下better-performing implementations的?
  3. 既然Math的实现,也是直接调用StrictMath,为什么结果确不一样呢?

原来,JVM为了让各个arch的CPU能够充分的发挥自己CPU的优势,会根据架构不同,会通过Hotspot intrinsics替换掉Math函数的实现,我们可以从代码vmSymbols.hpp看到,Math的很多实现都被替换掉了。log的替换类似于:

do_intrinsic(_dlog, java_lang_Math, log_name, double_double_signature, F_S)

最终,Math的调用为下图红色部分:

image

log的实现:

  • 在x86下,最终其实调用的是assembler_x86.cpp中的flog实现:

    void Assembler::flog() {
    fldln2();
    fxch();
    fyl2x();
    }
  • 而在aarch64下,我们可以从src/hotspot/cpu/目录下看到,aarch64并未实现优化版本。因此,实际aarch64调用的就是标准的StrictMath。

正因如此,x86汇编的计算结果的差异导致了x86和aarch64结果在Math.log差异。

当然,aarch64也在JDK 11中,对部分的Math接口做了加速实现,有兴趣可以看看JEP 315: Improve Aarch64 Intrinsics的实现。

3. toRadians的小插曲

在ARM优化过程中,有的是因为Math库和StrictMath不同的实现造成结果不同,所以我们如果对精度要求非常高,直接切到StrictMath即可。

但有的函数,由于在Java大版本升级的过程中,出现了一些实现的差异,先看一个简单的Java程序

public class Hello {
public static void main(String[] args) {
System.out.println("Math.toRadians(0.33): " + Math.toRadians(0.33));
System.out.println("StrictMath.toRadians(0.33): " + StrictMath.toRadians(0.33));
}
}

我们分别看看在Java11和Java8的结果:

$ /usr/lib/jvm/java-11-openjdk-amd64/bin/java Hello
Math.toRadians(0.33): 0.005759586531581287
StrictMath.toRadians(0.33): 0.005759586531581287
$ /usr/lib/jvm/java-1.8.0-openjdk-amd64/bin/java Hello
Math.toRadians(0.33): 0.005759586531581288
StrictMath.toRadians(0.33): 0.005759586531581288

最后一位很奇怪的差了1,我们继续深入进去看到toRadians的实现:

  • Java8的实现为:

    // Java 8 
    public static double toDegrees(double angrad) {
    return angrad * 180.0 / PI;
    }
  • Java11的实现为:

    private static final double DEGREES_TO_RADIANS = 0.017453292519943295;
    public static double toRadians(double angdeg) {
    return angdeg * DEGREES_TO_RADIANS;
    }

原来在Java11的实现中,为了优化性能,将* 180.0 / PI提前算好了,这样每次只用乘以乘数即可,从而化简了计算。这也最终导致了,Java8和Java11在精度上有一些差别。

  • Math在各个arch下的实现不同,精度也不同,如果对精度要求很高,可以使用StrictMath。
  • Java不同版本的优化,也有可能导致Math库的精度不同
  • Math库在实现时,利用intrinsics机制,把各个arch下Math的实现换掉了,从而充分的发挥各个CPU自身的优势。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK