【问题标题】:Hidden performance cost in Scala?Scala 中隐藏的性能成本?
【发布时间】:2014-04-30 03:25:39
【问题描述】:

我遇到了这个old question,并用 scala 2.10.3 做了以下实验。

我重写了 Scala 版本以使用显式尾递归:

import scala.annotation.tailrec

object ScalaMain {
  private val t = 20

  private def run() {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

并将其与以下 Java 版本进行比较。为了与 Scala 进行公平比较,我有意识地将函数设为非静态函数:

public class JavaMain {
    private final int t = 20;

    private void run() {
        int i = 10;
        while (!isEvenlyDivisible(2, i, t))
            i += 2;
        System.out.println(i);
    }

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b) return true;
        else return (a % i == 0) && isEvenlyDivisible(i+1, a, b);
    }

    public static void main(String[] args) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
          o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

这是我电脑上的结果:

> java JavaMain
....
time: 9651
> scala ScalaMain
....
time: 20592

这是(Java HotSpot(TM) 64 位服务器 VM,Java 1.7.0_51)上的 scala 2.10.3。

我的问题是 scala 版本的隐藏成本是多少?

非常感谢。

【问题讨论】:

  • 你能把它改成和scala里的代码完全一样吗?例如,if-else 语句可能不会被视为与isEvenlyDivisible 中的三元运算符完全相等。速度比较无关紧要,但现在的代码并不完全相等。另外,是否先预热 JVM,然后再提供 X 次运行的平均值?
  • 预热 JVM 很重要。反编译字节码也可能会产生一些见解。
  • 已编辑。我运行每个基准测试 20 次,并在 Scala 中使用 while 来避免创建闭包。根据经验,Scala 每次输出之间的时间间隔也更长。
  • 考虑到您正在测试解释器而不是 JITed 性能.. scala 代码运行速度可能会变慢并不令人意外 - 一些额外的间接。现在,如果我们正在测试实际的 JITed 代码?不太确定会有什么不同。
  • @Voo:进行 JITed 的正确方法是什么?我编译了这两个文件并运行了生成的.class 文件。无论如何,请参阅我的答案以获得更新的结果。

标签: java performance scala jvm microbenchmark


【解决方案1】:

我改了val

private val t = 20

到一个常量定义

private final val t = 20

并获得了显着的性能提升,现在看来两个版本的性能几乎相同[在我的系统上,请参阅更新和 cmets]。

我还没有研究过字节码,但如果你使用val t = 20,你可以看到使用javap 有一种方法(那个版本和private val 一样慢)。

所以我假设即使是 private val 也涉及调用方法,这与 Java 中的 final 无法直接比较。

更新

在我的系统上我得到了这些结果

Java 版本:时间:14725

Scala 版本:时间:13228

在 32 位 Linux 上使用 OpenJDK 1.7。

根据我的经验,Oracle 在 64 位系统上的 JDK 确实表现得更好,所以这可能解释了其他测量产生更好的结果,有利于 Scala 版本。

至于 Scala 版本的性能更好,我假设尾递归优化确实在这里产生了影响(请参阅 Phil 的回答,如果 Java 版本被重写为使用循环而不是递归,它再次执行同样的操作)。

【讨论】:

  • 实际上,您的新版本(以及将t 移至run 内部的版本)在我的计算机上运行速度是Java 的两倍。如果有人可以向我解释字节码中到​​底发生了什么,那就太好了。我对 JVM 没有经验,也不知道如何反编译字节码 :)
  • 我的印象也是,Scala版本的性能稍微好一点,可能是尾递归优化有效果。
  • 进行更改后,我得到了以下结果: scala time: 7852 java time: 14657 它不仅稍微快了一点,而且快了两倍!
  • @pedrofurla 我已将我系统中的结果添加到答案中。但是,是的,很高兴听到它在其他环境中具有如此巨大的影响。
  • 有关private final valprivate val 之间区别的相关答案,请参见stackoverflow.com/questions/13412386/…
【解决方案2】:

要使 Java 版本完全等同于您的 Scala 代码,您需要像这样进行更改。

private int t = 20;


private int t() {
    return this.t;
}

private void run() {
    int i = 10;
    while (!isEvenlyDivisible(2, i, t()))
        i += 2;
    System.out.println(i);
}

较慢是因为JVM无法优化方法调用。

【讨论】:

  • 嘿,谢谢!这个新版本的 Java 比我原来的 Java 版本稍慢(在我的计算机上为 11122 毫秒),但仍然比我原来的 Scala 快。但是请参阅速度更快的 Scala 版本的更新。
  • 为什么在这里投反对票?这是正确的表达方式。
【解决方案3】:

我查看了 this question 并编辑了 Scala 版本以在 run 中添加 t

object ScalaMain {
  private def run() {
    val t = 20
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

新的 Scala 版本现在的运行速度是原始 Java 版本的两倍:

> fsc ScalaMain.scala
> scala ScalaMain
....
time: 6373
> fsc -optimize ScalaMain.scala
....
time: 4703

我发现这是因为 Java 没有尾调用。使用循环而不是递归的优化 Java 运行速度一样快:

public class JavaMain {
    private static final int t = 20;

    private void run() {
        int i = 10;
        while (!isEvenlyDivisible(i, t))
            i += 2;
        System.out.println(i);
    }

    private boolean isEvenlyDivisible(int a, int b) {
        for (int i = 2; i <= b; ++i) {
            if (a % i != 0)
                 return false;
        }
        return true;
    }

    public static void main(String[] args) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
            o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

现在我的困惑完全解决了:

> java JavaMain
....
time: 4795

总之,最初的 Scala 版本很慢,因为我没有将 t 声明为 final(直接或间接地,正如 Berylliumanswer 指出的那样)。由于缺少尾调用,原始 Java 版本运行缓慢。

【讨论】:

  • 然后就是尾递归优化了。很高兴知道它按预期工作。
  • 为了完整起见,您是否也尝试在 java 中将 t 移动到 run 中?
  • @RobStarling 我做到了。差不多。似乎改变范围,Java 中的final-ness 和static-ness 没有影响。
【解决方案4】:

好吧,OP 的基准测试并不理想。需要减轻大量影响,包括预热、消除死代码、分叉等。幸运的是,JMH 已经处理了很多事情,并且绑定了 Java 和 Scala。请按照JMH页面的流程获取benchmark项目,然后可以移植下面的benchmarks。

这是示例 Java 基准测试:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
public class JavaBench {

    @Param({"1", "5", "10", "15", "20"})
    int t;

    private int run() {
        int i = 10;
        while(!isEvenlyDivisible(2, i, t))
            i += 2;
        return i;
    }

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b)
            return true;
        else
            return (a % i == 0) && isEvenlyDivisible(i + 1, a, b);
    }

    @GenerateMicroBenchmark
    public int test() {
        return run();
    }

}

...这是示例 Scala 基准测试:

@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
class ScalaBench {

  @Param(Array("1", "5", "10", "15", "20"))
  var t: Int = _

  private def run(): Int = {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    i
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i + 1, a, b)
  }

  @GenerateMicroBenchmark
  def test(): Int = {
    run()
  }

}

如果您在 JDK 8 GA、Linux x86_64 上运行这些,您将获得:

Benchmark             (t)   Mode   Samples         Mean   Mean error    Units
o.s.ScalaBench.test     1   avgt        15        0.005        0.000    us/op
o.s.ScalaBench.test     5   avgt        15        0.489        0.001    us/op
o.s.ScalaBench.test    10   avgt        15       23.672        0.087    us/op
o.s.ScalaBench.test    15   avgt        15     3406.492        9.239    us/op
o.s.ScalaBench.test    20   avgt        15  2483221.694     5973.236    us/op

Benchmark            (t)   Mode   Samples         Mean   Mean error    Units
o.s.JavaBench.test     1   avgt        15        0.002        0.000    us/op
o.s.JavaBench.test     5   avgt        15        0.254        0.007    us/op
o.s.JavaBench.test    10   avgt        15       12.578        0.098    us/op
o.s.JavaBench.test    15   avgt        15     1628.694       11.282    us/op
o.s.JavaBench.test    20   avgt        15  1066113.157    11274.385    us/op

请注意,我们对t 进行处理,以查看对于t 的特定值,效果是否是局部的。不是,效果系统,Java版快一倍。

PrintAssembly 将对此有所了解。这是 Scala 基准测试中最热门的块:

0x00007fe759199d42: test   %r8d,%r8d
0x00007fe759199d45: je     0x00007fe759199d76  ;*irem
                                               ; - org.sample.ScalaBench::isEvenlyDivisible@11 (line 52)
                                               ; - org.sample.ScalaBench::run@10 (line 45)
0x00007fe759199d47: mov    %ecx,%eax
0x00007fe759199d49: cmp    $0x80000000,%eax
0x00007fe759199d4e: jne    0x00007fe759199d58
0x00007fe759199d50: xor    %edx,%edx
0x00007fe759199d52: cmp    $0xffffffffffffffff,%r8d
0x00007fe759199d56: je     0x00007fe759199d5c
0x00007fe759199d58: cltd   
0x00007fe759199d59: idiv   %r8d

...这是Java中的类似块:

0x00007f4a811848cf: movslq %ebp,%r10
0x00007f4a811848d2: mov    %ebp,%r9d
0x00007f4a811848d5: sar    $0x1f,%r9d
0x00007f4a811848d9: imul   $0x55555556,%r10,%r10
0x00007f4a811848e0: sar    $0x20,%r10
0x00007f4a811848e4: mov    %r10d,%r11d
0x00007f4a811848e7: sub    %r9d,%r11d         ;*irem
                                              ; - org.sample.JavaBench::isEvenlyDivisible@9 (line 63)
                                              ; - org.sample.JavaBench::isEvenlyDivisible@19 (line 63)
                                              ; - org.sample.JavaBench::run@10 (line 54)

请注意,在 Java 版本中,编译器如何使用将整数余数计算转换为乘法和右移的技巧(参见 Hacker's Delight,第 10 章,第 19 节)。当编译器检测到我们根据常量计算余数时,这是可能的,这表明 Java 版本达到了这种甜蜜的优化,但 Scala 版本没有。您可以深入研究字节码反汇编以找出 scalac 中的哪些怪癖发生了干预,但本练习的重点是代码生成中令人惊讶的微小差异被基准放大了很多。

附: @tailrec 就这么多……

更新:更彻底的效果解释:http://shipilev.net/blog/2014/java-scala-divided-we-fail/

【讨论】:

  • 很好的答案。如果把这两种方法的字节码列表加起来就更好了!
猜你喜欢
  • 2011-03-03
  • 1970-01-01
  • 2012-05-23
  • 2017-01-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-08-06
相关资源
最近更新 更多