为什么Clojure在递归添加函数上比Scala快得多?

时间:2009-08-31 19:01:30

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

朋友在Clojure中给了我这段代码片段

(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中更快地实现算法。感谢。

4 个答案:

答案 0 :(得分:38)

首先,如果使用-optimise调用尾部调用,Scala只会优化尾调用。 修改:即使没有-optimise,Scala也会始终优化尾调用递归。

其次,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

这还比较慢,在这里跑。老实说,我不会期望它运行得更慢,但每次调用都会调用正在传递的函数。一旦你考虑到这一点,与递归版本相比,它是一个非常好的时间。

答案 1 :(得分:28)

Clojure的范围没有记忆,Scala的Stream确实如此。完全不同的数据结构具有完全不同的结果。 Scala确实有一个非memoizing 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

具有相似语义的unmemoized Range
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

与memoized Stream上的折叠对比

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

答案 2 :(得分:5)

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

  

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

编辑:Scala optimizes tail calls also,只要它们处于某种形式。但是,正如前面的链接所示,Scala只能在非常简单的情况下执行此操作:

  

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

如果没有实际编译和反编译你的代码以查看生成的JVM指令,我怀疑它不是那些简单的情况之一(正如迈克尔所说,因为必须在每个递归步骤中获取a.tail)并且因此Scala无法对其进行优化。

答案 3 :(得分:5)

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

在你的add函数中,你持有a.head的引用,因此垃圾收集器无法收集头部,导致最后保存9999998个元素的流,不能进行GC编辑。

[有点插曲]

你也可以保留你传递的尾巴的副本,我确定Stream如何处理它。如果您要使用列表,则会复制的尾部。例如:

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不太熟悉。

希望这会有所帮助:)