【问题标题】:Why is Clojure much faster than Scala on a recursive add function?为什么 Clojure 在递归添加函数上比 Scala 快得多?
【发布时间】:2010-11-24 10:33:28
【问题描述】:

一个朋友在 Clojure 中给了我这段代码 sn-p

(defn sum [coll acc] (if (empty? coll) acc (recur (rest coll) (+ (first coll) acc))))
(time (sum (range 1 9999999) 0))

并问我它与类似的 Scala 实现相比如何。

我编写的 Scala 代码如下所示:

def from(n: Int): Stream[Int] = Stream.cons(n, from(n+1))
val ints = from(1).take(9999998)

def add(a: Stream[Int], b: Long): Long = {
    if (a.isEmpty) b else add(a.tail, b + a.head)
}

val t1 = System.currentTimeMillis()
println(add(ints, 0))
val t2 = System.currentTimeMillis()
println((t2 - t1).asInstanceOf[Float] + " msecs")

底线是:Clojure 中的代码在我的机器上运行大约 1.8 秒,使用的堆少于 5MB,Scala 中的代码运行大约 12 秒,512MB 的堆还不够(如果它完成计算,如果我将堆设置为 1GB)。

所以我想知道为什么 Clojure 在这种特殊情况下会更快更苗条?您是否有在速度和内存使用方面具有相似行为的 Scala 实现?

请不要发表宗教言论,我的兴趣主要在于找出使 clojure 在这种情况下如此之快的原因,以及在 scala 中是否有更快的算法实现。谢谢。

【问题讨论】:

    标签: scala clojure performance tail-recursion tail-call-optimization


    【解决方案1】:

    首先,Scala 只有在您使用-optimise 调用它时才会优化尾调用。 编辑:如果可以的话,Scala 似乎总是会优化尾调用递归,即使没有-optimise

    其次,StreamRange 是两个非常不同的东西。 Range 有一个开始和一个结束,它的投影只有一个计数器和一个结束。 Stream 是一个将按需计算的列表。由于您要添加整个 ints,因此您将计算并分配整个 Stream

    更接近的代码是:

    import scala.annotation.tailrec
    
    def add(r: Range) = {
      @tailrec 
      def f(i: Iterator[Int], acc: Long): Long = 
        if (i.hasNext) f(i, acc + i.next) else acc
    
      f(r iterator, 0)
    }
    
    def time(f: => Unit) {
      val t1 = System.currentTimeMillis()
      f
      val t2 = System.currentTimeMillis()
      println((t2 - t1).asInstanceOf[Float]+" msecs")
    }
    

    正常运行:

    scala> time(println(add(1 to 9999999)))
    49999995000000
    563.0 msecs
    

    在 Scala 2.7 上,您需要“elements”而不是“iterator”,并且没有“tailrec”注解——该注解仅用于抱怨无法使用尾递归优化定义-- 所以你需要从代码中去掉“@tailrec”和“import scala.annotation.tailrec”。

    此外,还有一些关于替代实现的注意事项。最简单的:

    scala> time(println(1 to 9999999 reduceLeft (_+_)))
    -2014260032
    640.0 msecs
    

    平均而言,此处多次运行时速度较慢。这也是不正确的,因为它只适用于 Int。一个正确的:

    scala> time(println((1 to 9999999 foldLeft 0L)(_+_)))
    49999995000000
    797.0 msecs
    

    还是比较慢,跑这里。老实说,我没想到它会运行得更慢,但是每次交互都会调用正在传递的函数。考虑到这一点,与递归版本相比,这是一个相当不错的时机。

    【讨论】:

    • 当然,这会导致内存使用量增加。增加的计算时间怎么办?
    • 增加的计算时间花在分配内存上,而尝试垃圾回收却徒劳无功。
    • 如果你使用回收对象池,它会加速很多吗? JVM 处理短期堆对象的效率更像是一个堆栈,所以如果 GC 真的需要很多时间,我会感到惊讶。
    • @Bill K:提防这种说法。 Java 对短寿命堆对象的处理远不及堆栈效率,它只是比长寿命对象更好。堆栈释放是 O(1),而短期堆是 O(n),其中 n 是对象的数量。无论如何,是的,它比原始解决方案产生的数百万不可回收对象具有更好的性能,但它仍然会输给尾递归范围迭代器解决方案。
    • @Bill K:我熟悉垃圾收集器。 “恒定时间”垃圾收集器都做固定数量的工作——这与它们将处理的内存量成正比。在 Java 的情况下,线性因素来自识别必须保留哪些 Eden 对象并复制它们的工作。当伊甸园满时,即使是短暂的物体也可能是活的。所以我们有时间与根和活动对象的大小成线性关系。卡片标记之类的东西优化了 GC,但不会改变它的线性。同时,堆栈释放确实是 O(1):SP = BP; BP = POP SP
    【解决方案2】:

    Clojure 的范围不会记忆,Scala 的 Stream 会。完全不同的数据结构具有完全不同的结果。 Scala 确实有一个非记忆的 Range 结构,但目前以这种简单的递归方式使用它有点尴尬。这是我对整个事情的看法。

    在较慢的旧机器上使用 Clojure 1.0,我得到 3.6 秒

    user=> (defn sum [coll acc] (if (empty? coll) acc (recur (rest coll) (+ (first coll) acc))))
    #'user/sum
    user=> (time (sum (range 1 9999999) 0))
    "Elapsed time: 3651.751139 msecs"
    49999985000001
    

    Scala 的直译需要我写一些代码

    def time[T](x : => T) =  {
      val start = System.nanoTime : Double
      val result = x
      val duration = (System.nanoTime : Double) - start
      println("Elapsed time " + duration / 1000000.0 + " msecs")
      result
    }
    

    最好确保这是正确的

    scala> time (Thread sleep 1000)
    Elapsed time 1000.277967 msecs
    

    现在我们需要一个与 Clojure 语义相似的未记忆范围

    case class MyRange(start : Int, end : Int) {
      def isEmpty = start >= end
      def first = if (!isEmpty) start else error("empty range")
      def rest = new MyRange(start + 1, end)
    }
    

    从那个“添加”直接跟随

    def add(a: MyRange, b: Long): Long = {
        if (a.isEmpty) b else add(a.rest, b + a.first)
    }
    

    而且它比 Clojure 在同一个盒子上的速度要快得多

    scala> time(add(MyRange(1, 9999999), 0))
    Elapsed time 252.526784 msecs
    res1: Long = 49999985000001
    

    使用 Scala 的标准库 Range,您可以进行折叠。它没有简单的原始递归那么快,但它的代码更少,并且仍然比 Clojure 递归版本更快(至少在我的机器上)。

    scala> time((1 until 9999999 foldLeft 0L)(_ + _))
    Elapsed time 1995.566127 msecs
    res2: Long = 49999985000001
    

    对比记忆流的折叠

    time((Stream from 1 take 9999998 foldLeft 0L)(_ + _)) 
    Elapsed time 3879.991318 msecs
    res3: Long = 49999985000001
    

    【讨论】:

    • 为什么使用 foldLeft 比原始递归慢很多?
    【解决方案3】:

    我怀疑这是由于 Clojure 处理尾缆优化的方式。由于 JVM 本身并不执行此优化(Clojure 和 Scala 都在其上运行),因此 Clojure 通过 recur 关键字优化尾递归。来自Clojure site

    在函数式语言中循环和 迭代被替换/实现通过 递归函数调用。许多这样的 语言保证该功能 在尾部位置进行的呼叫不会 消耗堆栈空间,因此 递归循环利用常数 空间。由于 Clojure 使用 Java 调用约定,它不能,并且 没有,做同样的尾声 优化保证。相反,它 提供 recur 特殊运算符, 执行常量空间递归 通过重新绑定和跳转来循环 最近的封闭循环或函数 框架。虽然不像一般 尾调用优化,它允许大多数 具有相同的优雅结构,并且 提供检查的优势 重复调用只能发生在 尾部位置。

    编辑:Scala optimizes tail calls also,只要它们采用某种形式。但是,正如前面的链接所示,Scala 只能在非常简单的情况下这样做:

    实际上,这是 Scala 编译器的一个特性,称为尾调用优化。它 优化递归调用。此功能仅适用于上述简单情况, 尽管。如果递归是间接的,例如,Scala 无法优化尾调用, 因为有限的JVM指令集。

    没有实际编译和反编译代码以查看生成的 JVM 指令,我怀疑这不是那些简单的情况之一(正如 Michael 所说,由于必须在每个递归步骤中获取 a.tail),因此 Scala就是无法优化。

    【讨论】:

    • 我使用的是 scala 2.7.5,我认为它应该在我使用的场景中执行 t-c-o。
    • 我猜你最好确认一下,然后:-)
    • 根据下面的反编译字节码,看起来 t-c-o 正在完成。 public long add(scala.Stream, long);代码:0:aload_1 1:invokeinterface #103, 1; //InterfaceMethod scala/Seq.isEmpty:()Z 6: ifeq 11 9: lload_2 10: lreturn 11: aload_1 12: invokevirtual #106; //方法 scala/Stream.tail:()Lscala/Stream; 15:lload_2 16:aload_1 17:invokevirtual #110; //方法scala/Stream.head 20:invokestatic #114; //方法 scala/runtime/BoxesRunTime.unboxToInt 23: i2l 24: ladd 25: lstore_2 26: astore_1 27: goto 0
    【解决方案4】:

    分析了你的这个例子,似乎Stream 类(嗯......一些与之相关的匿名函数 - 因为visualvm在我身上崩溃而忘记了它的名字)占据了大部分堆。 这与 Scala 中的 Streams 确实泄漏内存有关 - 请参阅 Scala Trac #692。修复将在 Scala 2.8 中到期。编辑: Daniel 的评论正确地指出它与此错误无关。这是因为“val ints 指向 Stream 头部,垃圾收集器无法收集任何东西”[Daniel]。我发现这个错误报告中的 cmets 很好读,与这个问题有关。

    在您的 add 函数中,您持有对 a.head 的引用,因此垃圾收集器无法收集头部,导致最终包含 9999998 个元素的流,无法进行 GC。

    [小插曲]

    你也可以保留你不断经过的尾巴的副本,我确定Streams 是如何处理的。如果您使用列表,尾部将不会被复制。例如:

    val xs =  List(1,2,3)
    val ys = 1 :: xs
    val zs = 2 :: xs
    

    在这里,yszs 都“共享”相同的尾部,至少在堆方面(ys.tail eq zs.tail,又名引用相等产生 true)。

    [这个小插曲是为了说明原则上传递很多尾巴并不是一件坏事:),它们不会被复制,至少对于列表来说]

    另一种实现(运行速度非常快,我认为它比纯函数式更清晰)是使用命令式方法:

    def addTo(n: Int, init: Int): Long = {
      var sum = init.toLong
      for(i <- 1 to n) sum += i
      sum
    }
    
    scala> addTo(9999998, 0)
    

    在 Scala 中,为了性能和清晰度,使用命令式方法是完全可以的(至少对我来说,这个版本的 add 更清楚它的意图)。为了更简洁,你甚至可以写

    (1 to 9999998).reduceLeft(_ + _)
    

    (运行有点慢,但仍然合理,不会炸毁内存)

    我相信 Clojure 可能会更快,因为它功能齐全,因此比 Scala(它融合了函数式、OO 和命令式)可以进行更多优化。不过我对 Clojure 不是很熟悉。

    希望这会有所帮助:)

    【讨论】:

    • 与bug无关。因为val ints 指向Stream 头部,所以垃圾收集器无法收集任何东西。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-01-09
    • 1970-01-01
    • 1970-01-01
    • 2019-01-19
    • 1970-01-01
    相关资源
    最近更新 更多