Scala中隐藏的性能成本是什么?

55
我看到了这个旧问题,并用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-Bit Server VM, Java 1.7.0_51)上的Scala 2.10.3。

我的问题是,使用这个Scala版本有什么潜在成本?

非常感谢。


2
你能否将它更改为与Scala代码完全相等?例如,if-else语句可能不被视为isEvenlyDivisible中三元运算符的完全相等。这对于速度比较来说并不重要,但现在的代码并不完全相等。此外,你是否已经预热了JVM,并在X次运行后提供了平均值? - skiwi
5
热身JVM很重要。同时反编译字节码可能会带来一些见解。 - Jason C
我运行了每个基准测试20次,并在Scala中使用while来避免闭包的创建。经验证明,Scala输出之间的时间间隔也更长。 - Phil
考虑到您正在测试解释器而不是JIT性能...当然,Scala代码运行较慢并不令人惊讶-存在一些额外的间接操作。现在,如果我们正在测试实际的JIT代码呢?不确定是否会有任何区别。 - Voo
@Voo:JIT编译的正确方式是什么?我编译了两个文件并运行了生成的.class文件。无论如何,请查看我的答案以获取更新后的结果。 - Phil
@Phil 这个问题将至少回答你关于这个问题的一些疑问。不幸的是,Cliff在JavaOne上的论文似乎已经不再在线了,但是那些SO帖子本身就非常好。 (特别是有关预热的接受答案的第一个要点)。 - Voo
4个回答

136

嗯,OP的基准测试并不理想。需要考虑许多影响因素,包括热身、死代码消除、分叉等。幸运的是,JMH已经处理了许多事情,并且具有Java和Scala的绑定。请按照JMH页面上的程序获取基准测试项目,然后可以将下面的基准测试移植到那里。

这是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 进行调整以查看特定值的效果是否为局部效果。然而事实并非如此,该效果是系统性的,且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版本中,编译器采用了一种技巧将整数余数计算转化为乘法和右移(参见《黑客的艺术》第10章第19节)。当编译器检测到我们针对常量计算余数时,就可以实现这一点。这表明Java版本实现了这种优化,但Scala版本没有。您可以深入研究字节码反汇编以确定scalac中的哪些怪癖干扰了该过程,但本次练习的重点是代码生成中出现的惊人微小差异会被基准测试放大很多倍。
附注:@tailrec也就没什么用了...
更新:更详细的解释请参见:http://shipilev.net/blog/2014/java-scala-divided-we-fail/

1
很棒的答案。如果您添加了这两种方法的字节码列表,那将更加出色! - retronym

23
我已经修改了val
private val t = 20

转换为常量定义

private final val t = 20

并且得到了显著的性能提升,现在似乎两个版本的表现几乎相同[在我的系统上,请参见更新和评论]。
我没有研究过字节码,但如果您使用val t = 20,则可以使用javap查看是否存在一个方法(该版本与具有private val的版本一样慢)。
因此,我认为即使是private val也涉及调用方法,并且这与Java中的final不直接可比。
更新
在我的系统上,我得到了以下结果
Java版本:时间:14725 Scala版本:时间:13228
在32位Linux上使用OpenJDK 1.7。
根据我的经验,在64位系统上使用Oracle的JDK实际上表现更好,因此这可能解释了其他测量结果对Scala版本更有利的情况。
至于Scala版本表现更好,我认为尾递归优化在这里确实起了作用(请参见Phil的答案,如果将Java版本重写为使用循环而不是递归,则其表现再次相等)。

实际上,您的新版本(以及将t移动到run内部的版本)在我的计算机上运行速度是Java的两倍。如果有人能向我解释字节码中到底发生了什么,那就太好了。我对JVM没有经验,也不知道如何反编译字节码 :) - Phil
我的印象也是如此,Scala版本的性能略微更好,可能尾递归优化起了作用。 - Beryllium
1
进行这个更改后,我得到了以下结果: Scala 时间:7852 Java 时间:14657 它不仅稍微快一点,而且是两倍的速度! - pedrofurla
@pedrofurla 我已将我的系统结果添加到答案中。但是,很高兴听到它在其他环境中有如此巨大的影响。 - Beryllium
请参考https://dev59.com/yGYr5IYBdhLWcg3wxM0k,了解有关`private final valprivate val`之间差异的相关答案。 - Dave Swartz
显示剩余2条评论

7

我看了一下这个问题,并编辑了Scala版本的代码,将t放进了run中:

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(直接或间接地,正如Beryllium答案所指出的那样)。而原始的Java版本由于缺乏尾调用而变得缓慢。


然后是尾递归优化。很高兴知道它能够按预期工作。 - Beryllium
为了完整性,您是否也尝试在Java中将t移动到run中? - Rob Starling
1
@RobStarling 我试过了,结果差不多。似乎在Java中改变作用域、finalstatic都没有影响。 - Phil

1
要使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版本。 - Phil
为什么会有人踩这个问题?这是一个正确的表达方式。 - pedrofurla

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