如何在Scala中优化理解和循环?


131

因此,Scala应该和Java一样快。我正在重新研究最初在Java中解决的Scala Project Euler问题。尤其是问题5:“能被1到20的所有数均分的最小正数是多少?”

这是我的Java解决方案,需要0.7秒才能在我的计算机上完成:

public class P005_evenly_divisible implements Runnable{
    final int t = 20;

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

    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) {
        new P005_evenly_divisible().run();
    }
}

这是我对Scala的“直接翻译”,需要103秒(长147倍!)

object P005_JavaStyle {
    val t:Int = 20;
    def run {
        var i = 10
        while(!isEvenlyDivisible(i,t))
            i += 2
        println(i)
    }
    def isEvenlyDivisible(a:Int, b:Int):Boolean = {
        for (i <- 2 to b)
            if (a % i != 0)
                return false
        return true
    }
    def main(args : Array[String]) {
        run
    }
}

最后,这是我进行函数式编程的尝试,该过程需要39秒(长55倍)

object P005 extends App{
    def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
    def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
    println (find (2))
}

在Windows 7 64位上使用Scala 2.9.0.1。如何改善效能?难道我做错了什么?还是Java快了很多?


2
使用scala shell进行编译或解释?
AhmetB-Google 2011年

有比使用试行法(Hint)更好的方法。
hammar 2011年

2
您不会显示自己如何安排时间。您是否尝试过仅对run方法计时?
亚伦·诺夫斯特鲁普

2
@hammar-是的,只是笔和纸的方式:记下每个数字从高到高的素因数,然后划掉较高数字所具有的因数,所以以(5 * 2 * 2)结尾*(19)*(3 * 3)*(17)*(2 * 2)*()*(7)*(13)*()*(11)= 232792560
路易吉·普林格

2
+1这是我数周以来在SO上看到的最有趣的问题(这也是我一段时间以来看到的最佳答案)。
米娅·克拉克

Answers:


111

在这种情况下,问题是您从for表达式中返回。依次将其转换为抛出NonLocalReturnException的异常,该异常在封装方法中捕获。优化器可以消除前馈,但仍不能消除抛出/捕获。而且投掷/接球很昂贵。但是由于这样的嵌套返回在Scala程序中很少见,因此优化器尚未解决这种情况。有一些工作正在进行中,以改进优化器,希望它能尽快解决此问题。


9
退货成为例外非常重。我确定它已记录在某处,但它具有难以理解的隐藏魔术的气息。那真的是唯一的方法吗?
skrebbel 2011年

10
如果返回是从闭包内部发生的,那么它似乎是最好的选择。来自外部闭包的返回当然会直接转换为字节码中的返回指令。
Martin Odersky

1
我确定我会忽略某些内容,但是为什么不编译从闭包内部返回的值,以设置一个封闭的布尔标志和返回值,并在闭包调用返回后进行检查?
路加·胡特曼2011年

9
为什么他的功能算法仍然慢55倍?看起来它不应该遭受如此可怕的表现
伊利亚(Elijah)

4
现在,在2014年,我再次进行了测试,对我来说,性能如下:java-> 0.3s; 斯卡拉-> 3.6秒; 优化scala-> 3.5s; scala功能-> 4s; 看起来比3年前要好得多,但是...仍然相差太大。我们可以期待更多的性能改进吗?换句话说,马丁,理论上还有什么可能要做的优化吗?
sasha.sochka 2014年

80

问题很可能是for在方法中使用了理解isEvenlyDivisiblefor用等效while循环代替应该消除Java的性能差异。

与Java for循环相反,Scala的for理解实际上是高阶方法的语法糖。在这种情况下,您是foreachRange对象上调用方法。Scala的用法for很笼统,但有时会导致痛苦的表现。

您可能想-optimize在Scala 2.9版中尝试使用该标志。观察到的性能可能取决于所使用的特定JVM,并且JIT优化器具有足够的“热身”时间来识别和优化热点。

关于邮件列表的最新讨论表明,Scala团队正在努力改善for简单情况下的性能:

这是错误跟踪器中的问题:https : //issues.scala-lang.org/browse/SI-4633

更新5/28

  • 作为一种短期解决方案,ScalaCL插件(alpha)会将简单的Scala循环转换为等效的while循环。
  • 作为潜在的长期解决方案,EPFL和斯坦福大学的团队正在合作进行一个项目,该项目可对“虚拟” Scala进行运行时编译以实现非常高的性能。例如,可以在运行时将多个惯用功能循环融合到最佳JVM字节码中,或融合到另一个目标(例如GPU)上。该系统是可扩展的,允许用户定义DSL和转换。查看出版物和斯坦福大学课程笔记。初步代码可在Github上获得,并计划在未来几个月内发布。

6
太好了,我用while循环替换了for comprehension,它的运行速度与Java版本完全相同(+/- <1%)。谢谢...我差一点就失去了对Scala的信心!现在只需要研究一种好的功能算法... :)
Luigi Plinge 2011年

24
值得注意的是,尾递归函数也与while循环一样快(因为这两个都转换为非常相似或相同的字节码)。
雷克斯·克尔

7
这也让我一次。由于难以置信的速度降低,不得不将算法从使用集合函数转换为嵌套的while循环(级别6!)。恕我直言,这是需要高度针对性的事情。如果在需要良好的性能(请注意:不要急于提高性能)时不能使用它,那么好的编程风格有什么用呢?
拉斐尔

7
for那什么时候合适呢?
OscarRyz

@OscarRyz-scala中的for在大多数情况下都与Java中的for(:)一样。
Mike Axiak 2011年

31

作为后续,我尝试了-optimize标志,它将运行时间从103秒减少到了76秒,但是仍然比Java或while循环慢107倍。

然后我在看“功能”版本:

object P005 extends App{
  def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

并试图找出如何以简洁的方式摆脱“所有人”的问题。我惨败了,想出了

object P005_V2 extends App {
  def isDivis(x:Int):Boolean = {
    var i = 1
    while(i <= 20) {
      if (x % i != 0) return false
      i += 1
    }
    return true
  }
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

因此,我狡猾的5行解决方案已减少到12行。但是,此版本的运行时间为0.71秒,与原始Java 版本的运行速度相同,比使用“ forall”(40.2 s)的上述版本快56倍!(有关为什么它比Java更快的原因,请参见下面的编辑)

显然,我的下一步是将以上内容转换回Java,但是Java无法处理它,并在22000标记附近抛出StackOverflowError,其中n为n。

然后,我挠了一下头,然后用更多的尾部递归替换了“ while”,这节省了几行,并且运行得一样快,但让我们面对现实,这会使阅读起来更加混乱:

object P005_V3 extends App {
  def isDivis(x:Int, i:Int):Boolean = 
    if(i > 20) true
    else if(x % i != 0) false
    else isDivis(x, i+1)

  def find(n:Int):Int = if (isDivis(n, 2)) n else find (n+2)
  println (find (2))
}

因此,Scala的尾部递归赢得了胜利,但是令我惊讶的是,诸如“ for”循环(以及“ forall”方法)之类的简单操作本质上已被破坏,并且必须由笨拙且冗长的“ whiles”或尾递归来代替。我尝试Scala的很多原因是因为语法简洁,但是如果我的代码运行速度慢100倍,那就不好了!

编辑:(已删除)

编辑:以前在2.5s和0.7s之间的运行时间差异完全是由于使用32位还是64位JVM。命令行中的Scala使用JAVA_HOME设置的内容,而Java使用64位(如果可用)。IDE具有自己的设置。这里的一些测量:Eclipse中的Scala执行时间


1
isDivis方法可以写成:def isDivis(x: Int, i: Int): Boolean = if (i > 20) true else if (x % i != 0) false else isDivis(x, i+1)。注意,在Scala中,if-else是始终返回值的表达式。此处无需返回关键字。
kiritsuku 2011年

3
你的最后一个版本(P005_V3:)可以更短,更声明恕我直言更清晰的通过书面形式提出def isDivis(x: Int, i: Int): Boolean = (i > 20) || (x % i == 0) && isDivis(x, i+1)
Blaisorblade

@Blaisorblade否。这将破坏尾递归性,而后者需要转换为字节码中的while循环,从而使执行速度更快。
gzm0

4
我明白您的意思,但是由于&&和||,我的示例仍然是尾递归的 使用短路评估,通过使用@tailrec进行确认:gist.github.com/Blaisorblade/5672562
Blaisorblade 2013年

8

关于理解的答案是正确的,但这不是全部。您应该注意,returnin 的使用isEvenlyDivisible不是免费的。在中使用return会for强制Scala编译器生成非本地返回(即在函数外部返回)。

这是通过使用异常退出循环来完成的。如果您构建自己的控件抽象,也会发生相同的情况,例如:

def loop[T](times: Int, default: T)(body: ()=>T) : T = {
    var count = 0
    var result: T = default
    while(count < times) {
        result = body()
        count += 1
    }
    result
}

def foo() : Int= {
    loop(5, 0) {
        println("Hi")
        return 5
    }
}

foo()

这只会打印“ Hi”一次。

请注意,returnin foo出口foo(这是您期望的)。由于方括号括起来的表达式是函数文字,您可以在签名中看到loop这迫使编译器生成非本地返回值,即,return迫使您退出foo,而不仅仅是body

在Java(即JVM)中,实现这种行为的唯一方法是引发异常。

回到isEvenlyDivisible

def isEvenlyDivisible(a:Int, b:Int):Boolean = {
  for (i <- 2 to b) 
    if (a % i != 0) return false
  return true
}

if (a % i != 0) return false是有回报的功能文字,所以每次返回被击中时,运行时必须抛出和捕捉异常,导致相当多的GC的开销。


6

加快forall我发现的方法的一些方法:

原稿:41.3秒

def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}

预先实例化范围,因此我们不会每次都创建一个新范围:9.0 s

val r = (1 to 20)
def isDivis(x:Int) = r forall {x % _ == 0}

转换为列表而不是范围:4.8 s

val rl = (1 to 20).toList
def isDivis(x:Int) = rl forall {x % _ == 0}

我尝试了其他一些集合,但是List最快(尽管比我们完全避免使用Range和高阶函数要慢7倍)。

虽然我是Scala的新手,但我猜编译器可以通过用最外面的作用域中的Range常量简单地自动替换方法(如上)中的Range文字来轻松实现快速而显着的性能提升。或者更好的是,像Java中的Strings文字一样对它们进行实习。


脚注:数组与Range大致相同,但是有趣的是,拉皮条新forall方法(如下所示)在64位上的执行速度提高了24%,在32位上的执行速度提高了8%。当我通过将因子数量从20减少到15来减小计算量时,差异消失了,所以这可能是垃圾回收效果。无论是什么原因,长时间在满负载下运行都非常重要。

List的类似皮条客也使性能提高了约10%。

  val ra = (1 to 20).toArray
  def isDivis(x:Int) = ra forall2 {x % _ == 0}

  case class PimpedSeq[A](s: IndexedSeq[A]) {
    def forall2 (p: A => Boolean): Boolean = {      
      var i = 0
      while (i < s.length) {
        if (!p(s(i))) return false
        i += 1
      }
      true
    }    
  }  
  implicit def arrayToPimpedSeq[A](in: Array[A]): PimpedSeq[A] = PimpedSeq(in)  

3

我只是想为可能对这样的问题失去信心的人们发表评论,因为这类问题几乎涉及所有功能语言的性能。如果要在Haskell中优化折叠,则通常必须将其重写为递归尾部调用优化循环,否则将遇到性能和内存问题。

我知道不幸的是,FP还没有进行优化,以至于我们不必考虑这样的事情,但这并不是Scala特有的问题。


2

已经讨论了Scala特有的问题,但是主要问题是使用蛮力算法不是很酷。考虑一下(比原始Java代码快得多):

def gcd(a: Int, b: Int): Int = {
    if (a == 0)
        b
    else
        gcd(b % a, a)
}
print (1 to 20 reduce ((a, b) => {
  a / gcd(a, b) * b
}))

这些问题比较了跨语言的特定逻辑的性能。该算法对于该问题是否最优并不重要。
smartnut007 2014年

1

尝试在解决方案Scala中为欧拉计划提供的单线

给定的时间至少比您的时间快,尽管距离while循环还很远.. :)


它与我的功能版本非常相似。您可以将我的代码写为def r(n:Int):Int = if ((1 to 20) forall {n % _ == 0}) n else r (n+2); r(2),比Pavel的字符短4个字符。:)但是我不认为我的代码有什么好处-当我发布这个问题时,我总共编写了大约30行Scala。
路易吉·普林格
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.