如何优化 Scala 中的 for-comprehensions 和循环?

Posted

技术标签:

【中文标题】如何优化 Scala 中的 for-comprehensions 和循环?【英文标题】:How to optimize for-comprehensions and loops in Scala? 【发布时间】:2011-09-03 00:32:16 【问题描述】:

所以 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))

在 64 位 Windows 7 上使用 Scala 2.9.0.1。如何提高性能?难道我做错了什么?还是 Java 更快?

【问题讨论】:

你是用scala shell编译还是解释? 有比使用试用除法更好的方法 (Hint)。 你没有展示你是如何计时的。您是否尝试过为run 方法计时? @hammar - 是的,只是用笔和纸的方式完成了:写下每个从高开始的数字的质因数,然后划掉你已经拥有的更高数字的因数,这样你就完成了与 (5*2*2)*(19)*(3*3)*(17)*(2*2)*()*(7)*(13)*()*(11) = 232792560跨度> +1 这是我几周来在 SO 上看到的最有趣的问题(这也是我在很长一段时间内看到的最佳答案)。 【参考方案1】:

在这种特殊情况下的问题是您从 for 表达式中返回。这反过来又被转换为抛出 NonLocalReturnException,它在封闭方法中被捕获。优化器可以消除 foreach 但还不能消除 throw/catch。投掷/接球很昂贵。但是由于这种嵌套返回在 Scala 程序中很少见,优化器还没有解决这种情况。正在改进优化器,希望能很快解决这个问题。

【讨论】:

相当沉重,返回成为异常。我敢肯定它在某处被记录在案,但它具有无法理解的隐藏魔法的气味。这真的是唯一的方法吗? 如果返回发生在闭包内部,这似乎是最好的选择。来自外部闭包的返回当然会直接转换为字节码中的返回指令。 我确定我忽略了一些东西,但为什么不编译从闭包内部的返回来设置封闭的布尔标志和返回值,并在闭包调用返回后检查呢? 为什么他的函数式算法还是慢了 55 倍?它看起来不应该遭受如此糟糕的表现 现在,在 2014 年,我再次对其进行了测试,对我来说性能如下:java -> 0.3s;斯卡拉-> 3.6s; scala优化-> 3.5s; scala函数-> 4s;看起来比三年前好多了,但是……还是差距太大了。我们可以期待更多的性能改进吗?换句话说,Martin,理论上还有什么可以优化的吗?【参考方案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
))

【讨论】:

这些问题比较了跨语言的特定逻辑的性能。算法是否对问题最优并不重要。【参考方案3】:

尝试解决方案中给出的单行Scala for Project Euler

给出的时间至少比你的快,虽然离while循环很远.. :)

【讨论】:

和我的功能版很相似。您可以将我的写为def r(n:Int):Int = if ((1 to 20) forall n % _ == 0) n else r (n+2); r(2),它比 Pavel 的短 4 个字符。 :) 但是我并没有假装我的代码有什么好处——当我发布这个问题时,我总共编写了大约 30 行 Scala 代码。【参考方案4】:

我只是想为那些可能对 Scala 失去信心的人发表评论,因为这类问题几乎出现在所有函数式语言的性能中。如果您在 Haskell 中优化折叠,您通常必须将其重写为递归尾调用优化循环,否则您将面临性能和内存问题。

我知道不幸的是,FP 还没有优化到我们不必考虑这样的事情的地步,但这根本不是 Scala 特有的问题。

【讨论】:

【参考方案5】:

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

原版:41.3 秒

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

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

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

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

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

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

虽然我是 Scala 的新手,但我猜编译器可以轻松地实现快速且显着的性能提升,方法是简单地用最外层范围内的 Range 常量自动替换方法中的 Range 字面量(如上)。或者更好的是,像 Java 中的字符串文字一样实习。


脚注: 数组与 Range 大致相同,但有趣的是,pimping 一个新的forall 方法(如下所示)导致在 64 位上的执行速度提高了 24%,在 32 位上提高了 8%。当我通过将因子数量从 20 减少到 15 来减少计算量时,差异消失了,所以可能是垃圾收集效应。不管是什么原因,在长时间满负荷运行时,这一点很重要。

List 的类似 pimp 也使性能提高了约 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)  

【讨论】:

【参考方案6】:

关于理解的答案是正确的,但这不是全部。请注意,returnisEvenlyDivisible 中的使用不是免费的。在for 中使用 return 会强制 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”。

请注意,foo 中的 return 退出 foo(这是您所期望的)。由于括号中的表达式是一个函数文字,您可以在loop 的签名中看到它,这会强制编译器生成非本地返回,即return 强制您退出foo,而不仅仅是@ 987654331@.

在 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 开销。

【讨论】:

【参考方案7】:

作为后续,我尝试了 -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))

并试图弄清楚如何以简洁的方式摆脱“forall”。我悲惨地失败了,然后想出了

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秒)快56倍! (请参阅下面的编辑了解为什么这比 Java 更快)

显然,我的下一步是将上面的内容转换回 Java,但 Java 无法处理它并抛出一个 ***Error,n 在 22000 左右。

然后我摸了摸头,用更多的尾递归替换了“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 倍,那就不好了!

编辑:(已删除)

EDIT OF EDIT:之前运行时间 2.5 秒和 0.7 秒之间的差异完全是由于使用的是 32 位还是 64 位 JVM。命令行中的 Scala 使用 JAVA_HOME 设置的任何内容,而 Java 使用 64 位(如果可用)无论如何。 IDE 有自己的设置。这里有一些测量:Scala execution times in Eclipse

【讨论】:

isDivis 方法可以写成:def isDivis(x: Int, i: Int): Boolean = if (i &gt; 20) true else if (x % i != 0) false else isDivis(x, i+1)。请注意,在 Scala 中 if-else 是一个始终返回值的表达式。这里不需要返回关键字。 您的最新版本 (P005_V3) 可以通过以下方式变得更短、更具声明性和恕我直言:def isDivis(x: Int, i: Int): Boolean = (i &gt; 20) || (x % i == 0) &amp;&amp; isDivis(x, i+1) @Blaisorblade 否。这会破坏尾递归,这是在字节码中转换为 while 循环所必需的,从而加快执行速度。 我明白你的意思,但我的例子仍然是尾递归的,因为 && 和 ||使用短路评估,通过使用@tailrec 确认:gist.github.com/Blaisorblade/5672562【参考方案8】:

问题很可能是在方法isEvenlyDivisible 中使用了for 推导式。用等效的 while 循环替换 for 应该可以消除与 Java 的性能差异。

与 Java 的 for 循环相反,Scala 的 for 推导式实际上是高阶方法的语法糖;在这种情况下,您在 Range 对象上调用 foreach 方法。 Scala 的for 很笼统,但有时会导致痛苦的性能。

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

最近在邮件列表上的讨论表明,Scala 团队正在努力提高 for 在简单情况下的性能:

http://groups.google.com/group/scala-user/browse_thread/thread/86adb44d72ef4498 http://groups.google.com/group/scala-language/browse_thread/thread/94740a10205dddd2

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

5/28 更新

作为一个短期解决方案,ScalaCL 插件 (alpha) 会将简单的 Scala 循环转换为等效的 while 循环。 作为潜在的长期解决方案,来自 EPFL 和斯坦福大学的团队 collaborating on a project 启用 "virtual" Scala 的运行时编译以获得非常高的性能。例如,多个惯用的功能循环可以fused at run-time 转换为最佳 JVM 字节码,或转换为 GPU 等其他目标。该系统是可扩展的,允许用户定义 DSL 和转换。查看publications 和斯坦福course notes。初步代码可在 Github 上获得,预计在未来几个月内发布。

【讨论】:

太好了,我用 while 循环替换了 for 理解,它的运行速度与 Java 版本完全相同(+/- 值得注意的是尾递归函数也和while循环一样快(因为两者都被转换为非常相似或相同的字节码)。 这也吸引了我一次。由于速度慢得令人难以置信,不得不将算法从使用集合函数转换为嵌套的 while 循环(第 6 级!)。这是需要重点针对的东西,恕我直言;如果我在需要不错的(注意:不是极快的)性能时不能使用它,那么好的编程风格有什么用? 那么for什么时候合适? @OscarRyz - scala 中的 for 在大多数情况下与 java 中的 for ( : ) 相同。

以上是关于如何优化 Scala 中的 for-comprehensions 和循环?的主要内容,如果未能解决你的问题,请参考以下文章

优化Scala代码以读取不适合内存的大文件的有效方法

Scala 中的高效字符串连接

第3节 Scala中的模式匹配:1 - 5

尝试使用 ProGuard 优化 Java+Scala 时出现 java.lang.***Error

PySpark UDF 优化挑战使用带有正则表达式的字典(Scala?)

如何访问 Scala 中的测试资源?