在我第一次尝试创建功能代码时,我遇到了性能问题。
我从一个共同的任务开始 - 将两个数组的元素相乘并总结结果:
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倍的时间才能完成!
为什么第二种方法需要更长的时间?如何将工作改造为速度效率和使用函数式编程风格?
答案 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的功能,可以摆脱很多这些装箱/拆箱。但是让我们考虑替代更快的版本。例如,我们可以一步完成map
和reduceLeft
:
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
更好,但不是很多。很遗憾,我们无法将zipped
与foldLeft
合并,因为前者不支持后者。
最后一个是我能得到的最快的。比这快,只有专业化。现在,Function2
恰好是专门的,但适用于Int
,Long
和Double
。其他原语被省略了,因为专门化会为每个原语增加相当大的代码大小。在我的测试中,虽然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),足够智能的编译器将能够自动并行化您的代码,从而获得更多的性能优势。
所以,总结一下:
答案 3 :(得分:14)
Scala是一种很好的工具,用于尝试不同寻常的语言思想混合的表达能力:类,混合,模块,函数等。但它是一种相对年轻的研究语言,它运行在JVM上,因此除了JVM擅长的代码之外,期望出色的性能是不合理的。如果你想尝试Scala提供的混合语言思想,那么它很棒 - 这是一个非常有趣的设计 - 但是不要期望在功能语言的成熟编译器上获得相同的纯函数代码性能,比如GHC或MLton。
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)
您的功能解决方案很慢,因为它会生成不必要的临时数据结构。删除这些被称为砍伐森林,通过将匿名函数滚动到单个匿名函数并使用单个聚合器,可以在严格的函数语言中轻松完成。例如,使用zip
,map
和reduce
以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。 :)