Scala函数式编程比传统编码慢吗?

时间:2010-05-08 16:42:25

标签: performance scala functional-programming

在我第一次尝试创建功能代码时,我遇到了性能问题。

我从一个共同的任务开始 - 将两个数组的元素相乘并总结结果:

var first:Array[Float] ...
var second:Array[Float] ...    
var sum=0f; 
for (ix<-0 until first.length) 
    sum += first(ix) * second(ix);

以下是我改革工作的方式:

sum = first.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)

当我对两种方法进行基准测试时,第二种方法需要40倍的时间才能完成!

为什么第二种方法需要更长的时间?如何将工作改造为速度效率和使用函数式编程风格?

9 个答案:

答案 0 :(得分:65)

这两个例子速度如此不同的主要原因是:

  • 更快的一个不使用任何泛型,所以它不会面临装箱/拆箱。
  • 速度越快,不会创建临时集合,从而避免额外的内存副本。

让我们逐个考虑慢一点。第一:

first.zip(second)

创建一个新数组,一个Tuple2数组。它会将两个数组中的所有元素复制到Tuple2个对象中,然后将对这些对象的引用复制到第三个数组中。现在,请注意Tuple2已参数化,因此无法直接存储Float。相反,为每个数字创建java.lang.Float的新实例,将数字存储在其中,然后将每个数字的引用存储到Tuple2中。

map{ case (a,b) => a*b }

现在创建了第四个数组。要计算这些元素的值,需要从第三个数组中读取对元组的引用,读取存储在其中的java.lang.Float的引用,读取数字,乘以,创建一个新的java.lang.Float存储结果,然后将此引用传回,将再次 de -referenced存储在数组中(数组不会被类型擦除)。

但是,我们还没完成。这是下一部分:

reduceLeft(_+_)

那个是相对无害的,除了它仍然在迭代时进行装箱/拆箱和java.lang.Float创建,因为reduceLeft收到Function2,这是参数化的。

Scala 2.8引入了一项名为specialization的功能,可以摆脱很多这些装箱/拆箱。但是让我们考虑替代更快的版本。例如,我们可以一步完成mapreduceLeft

sum = first.zip(second).foldLeft(0f) { case (a, (b, c)) => a + b * c }

我们可以使用view(Scala 2.8)或projection(Scala 2.7)来避免完全创建中间集合:

sum = first.view.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)

实际上,这最后一个并没有节省太多,所以我认为非严格性如果被“丢失”得相当快(即,即使在视图中这些方法中的一个也是严格的)。还有一种替代的压缩方法,默认情况下是非严格的(即,避免一些中间结果):

sum = (first,second).zipped.map{ case (a,b) => a*b }.reduceLeft(_+_)

这给前者带来了更好的结果。比foldLeft更好,但不是很多。很遗憾,我们无法将zippedfoldLeft合并,因为前者不支持后者。

最后一个是我能得到的最快的。比这快,只有专业化。现在,Function2恰好是专门的,但适用于IntLongDouble。其他原语被省略了,因为专门化会为每个原语增加相当大的代码大小。在我的测试中,虽然Double实际上需要更长的时间。这可能是因为它是两倍大小,或者可能是我做错了。

因此,最终,问题是多种因素的组合,包括生成元素的中间副本,以及Java(JVM)处理基元和泛型的方式。使用超级编译的Haskell中的类似代码将等于任何缺少汇编程序的代码。在JVM上,您必须了解权衡并准备好优化关键代码。

答案 1 :(得分:34)

我用Scala 2.8做了一些变化。循环版本就像你写的那样 功能版略有不同:

(xs, ys).zipped map (_ * _) reduceLeft(_ + _)

我使用Double而不是Float运行,因为目前专业化只为Double启动。然后,我使用数组和向量作为载体类型进行测试。此外,我测试了Boxed变体,它们可以在java.lang.Double上运行,而不是用于测量的原始双打 原始类型装箱和拆箱的效果。这是我得到的(运行Java 1.6_10服务器VM,Scala 2.8 RC1,每次测试运行5次)。

loopArray               461             437             436             437             435
reduceArray             6573            6544            6718            6828            6554
loopVector              5877            5773            5775            5791            5657
reduceVector            5064            4880            4844            4828            4926

loopArrayBoxed          2627            2551            2569            2537            2546
reduceArrayBoxed        4809            4434            4496            4434            4365
loopVectorBoxed         7577            7450            7456            7463            7432
reduceVectorBoxed       5116            4903            5006            4957            5122

首先要注意的是,到目前为止,最大的区别在于原始数组循环和原始数组功能减少。它大约是15而不是你看到的40,这反映了Scala 2.8的改进超过2.7。尽管如此,原始数组循环是所有测试中最快的,而原始数组减少是最慢的。原因是原始Java数组和通用操作不是很合适。从泛型函数访问原始Java数组的元素需要大量的装箱/拆箱,有时甚至需要反射。 Scala的未来版本将专门化Array类,然后我们应该看到一些改进。但是现在就是这样。

如果你从数组转向向量,你会注意到几件事。首先,reduce版本现在比命令式循环更快!这是因为矢量减少可以利用有效的批量操作。其次,向量减少比数组减少更快,这说明了原始类型数组为通用高阶函数带来的固有开销。

如果通过仅使用装箱的java.lang.Double值消除装箱/拆箱的开销,则图片会发生变化。现在减少数组比循环慢一点,而不是之前的15倍。这更接近于具有中间数据结构的三个循环的固有开销,而不是命令式版本的融合循环。现在,在向量上循环是最慢的解决方案,而减少向量比在减少数组方面要慢一点。

所以总的答案是:这取决于。如果你对原始值数组有紧密的循环,那么就没有什么能胜过命令式循环。编写循环没有问题,因为它们比功能版本既不长也不易理解。在所有其他情况下,FP解决方案看起来很有竞争力。

答案 2 :(得分:15)

这是一个微基准测试,它取决于编译器如何优化代码。这里有3个循环,

拉链。地图。折

现在,我很确定Scala编译器不能将这三个循环融合到一个循环中,并且底层数据类型是严格的,因此每个(。)对应于正在创建的中间数组。命令式/可变解决方案每次都会重用缓冲区,避免复制。

现在,了解构成这三个函数的含义对于理解函数式编程语言中的性能至关重要 - 实际上,在Haskell中,这三个循环将被优化为一个重用底层缓冲区的循环 - 但是Scala不能这样做。

坚持组合方法有一些好处,但是通过区分这三个函数,可以更容易地并行化代码(用mapMap替换map等)。实际上,给定正确的数组类型(例如parallel array),足够智能的编译器将能够自动并行化您的代码,从而获得更多的性能优势。

所以,总结一下:

  • 幼稚的翻译可能会有意想不到的副本和低效率
  • 聪明的FP编译器删除了这个开销(但Scala还没有)
  • 如果您想重新定位代码,
  • 坚持高级方法会获得回报,例如:并行化

答案 3 :(得分:14)

Don Stewart有一个很好的答案,但是可能并不明显,从一个循环到三个循环会导致40减速因素。我会补充他的答案Scala编译为JVM字节码而且,Scala编译器不仅不会将三个循环融合为一个,而且Scala编译器几乎肯定会分配所有中间数组。众所周知,JVM的实现并非旨在处理功能语言所需的分配率。分配是功能程序中的一个重要成本,而Don Stewart和他的同事为Haskell实现的循环融合转换是如此强大:它们消除了大量的分配。当你没有这些转换时,加上你使用的是昂贵的分配器,例如在典型的JVM上找到的分配器,这就是大减速的来源。

Scala是一种很好的工具,用于尝试不同寻常的语言思想混合的表达能力:类,混合,模块,函数等。但它是一种相对年轻的研究语言,它运行在JVM上,因此除了JVM擅长的代码之外,期望出色的性能是不合理的。如果你想尝试Scala提供的混合语言思想,那么它很棒 - 这是一个非常有趣的设计 - 但是不要期望在功能语言的成熟编译器上获得相同的纯函数代码性能,比如GHCMLton

  

scala函数式编程是否比传统编码慢?

不一定。与一流功能,模式匹配和currying相关的东西不需要特别慢。但是使用Scala,而不是其他功能语言的其他实现,你真的必须注意分配 - 它们可能非常昂贵。

答案 4 :(得分:8)

Scala集合库是完全通用的,并且选择的操作是为了获得最大功能,而不是最大速度。所以,是的,如果你在没有注意的情况下使用Scala的功能范例(特别是如果你使用原始数据类型),那么你的代码运行时间(在大多数情况下)比你使用命令式/迭代范式而不注意

也就是说,您可以轻松创建非通用的功能操作,以便为您所需的任务快速执行。在使用浮动对的情况下,我们可能会执行以下操作:

class FastFloatOps(a: Array[Float]) {
  def fastMapOnto(f: Float => Float) = {
    var i = 0
    while (i < a.length) { a(i) = f(a(i)); i += 1 }
    this
  }
  def fastMapWith(b: Array[Float])(f: (Float,Float) => Float) = {
    val len = a.length min b.length
    val c = new Array[Float](len)
    var i = 0
    while (i < len) { c(i) = f(a(i),b(i)); i += 1 }
    c
  }
  def fastReduce(f: (Float,Float) => Float) = {
    if (a.length==0) Float.NaN
    else {
      var r = a(0)
      var i = 1
      while (i < a.length) { r = f(r,a(i)); i += 1 }
      r
    }
  }
}
implicit def farray2fastfarray(a: Array[Float]) = new FastFloatOps(a)

然后这些操作会快得多。 (如果你使用Double和2.8.RC1,则更快,因为那时函数(Double,Double)=>Double将是专用的,而不是通用的;如果你之前使用过某些东西,你可以创建自己的abstract class F { def f(a: Float) : Float }然后调用new F { def f(a: Float) = a*a }代替(a: Float) => a*a。)

无论如何,重点是它不是使Scala中的函数编码变慢的功能样式,而是库设计时考虑到最大功率/灵活性,而不是最大速度。这是明智的,因为每个人的速度要求通常略有不同,因此很难将每个人都覆盖得非常好。但是,如果你做的不仅仅是一点点,你可以编写自己的东西,其中功能风格的性能损失非常小。

答案 5 :(得分:5)

我不是专家Scala程序员,所以可能有一种更有效的方法,但是这样的事情呢。这可以是尾部调用优化,因此性能应该没问题。

def multiply_and_sum(l1:List[Int], l2:List[Int], sum:Int):Int = {
    if (l1 != Nil && l2 != Nil) {
        multiply_and_sum(l1.tail, l2.tail, sum + (l1.head * l2.head))
    }
    else {
        sum
    }
}

val first = Array(1,2,3,4,5)
val second = Array(6,7,8,9,10)
multiply_and_sum(first.toList, second.toList, 0)  //Returns: 130

答案 6 :(得分:3)

回答标题中的问题:简单的功能构造可能比JVM上的命令要慢。

但是,如果我们只考虑简单的结构,那么我们不妨抛弃所有现代语言并坚持使用C或汇编程序。如果你看一下编程语言的枪战,C总会获胜。

为什么选择现代语言?因为它可以让您表达更清洁的设计。更清洁的设计可以提高应用程序整体运行的性能。即使某些低级方法可能会更慢。我最喜欢的一个例子是BuildR vs. Maven的性能。 BuildR是用Ruby编写的,这是一种解释性的慢速语言。 Maven是用Java编写的。 BuildR中的内置速度是Maven的两倍。这主要归功于BuildR的设计,与Maven相比,它的重量轻。

答案 7 :(得分:2)

您的功能解决方案很慢,因为它会生成不必要的临时数据结构。删除这些被称为砍伐森林,通过将匿名函数滚动到单个匿名函数并使用单个聚合器,可以在严格的函数语言中轻松完成。例如,使用zipmapreduce以F#编写的解决方案:

let dot xs ys = Array.zip xs ys |> Array.map (fun (x, y) -> x * y) -> Array.reduce ( * )

可以使用fold2重写,以避免所有临时数据结构:

let dot xs ys = Array.fold2 (fun t x y -> t + x * y) 0.0 xs ys

这是 lot 更快,并且可以在Scala和其他严格的函数语言中完成相同的转换。在F#中,您还可以将fold2定义为inline,以便使用其功能参数内联高阶函数,从而恢复命令式循环的最佳性能。

答案 8 :(得分:1)

这是带有数组的dbyrnes解决方案(假设要使用数组)并且只是迭代索引:

def multiplyAndSum (l1: Array[Int], l2: Array[Int]) : Int = 
{
    def productSum (idx: Int, sum: Int) : Int = 
        if (idx < l1.length)
            productSum (idx + 1, sum + (l1(idx) * l2(idx))) else 
                sum
    if (l2.length == l1.length) 
        productSum (0, 0) else 
    error ("lengths don't fit " + l1.length + " != " + l2.length) 
}


val first = (1 to 500).map (_ * 1.1) toArray                                                
val second = (11 to 510).map (_ * 1.2) toArray     
def loopi (n: Int) = (1 to n).foreach (dummy => multiplyAndSum (first, second))
println (timed (loopi (100*1000)))

这需要大约1/40的列表方法时间。我没有安装2.8,所以你必须亲自测试@tailrec。 :)