Functional programming - is immutability expensive?
问题分为两部分。第一个是概念性的。接下来,我们将更具体地讨论scala中的同一个问题。
更多细节:
我来自一个命令式编程背景(C++,Java)。我一直在探索函数式编程,特别是scala。
纯函数编程的一些基本原则:
即使现代的JVM在对象创建方面非常有效,而垃圾收集对于短寿命的对象来说非常便宜,最小化对象创建可能还是更好的,对吧?至少在单线程应用程序中,并发性和锁定不是问题。由于scala是一个混合的范例,如果需要,可以选择用可变对象编写命令式代码。但是,作为一个花费了很多年试图重用对象和最小化分配的人。我想对思想流派有一个很好的理解,那甚至是不允许的。
作为一个具体的例子,我对本教程6中的代码片段有点惊讶。它有一个Java版本的Quask排序,后面还有一个整洁的Scala实现。
下面是我对实现进行基准测试的尝试。我还没有做详细的分析。但是,我的猜测是scala版本速度较慢,因为分配的对象数是线性的(每个递归调用一个)。尾叫优化是否有可能发挥作用?如果我是对的,scala支持对自递归调用进行尾调用优化。所以,这只会有帮助。我用的是scala 2.8。
Java版本1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class QuickSortJ { public static void sort(int[] xs) { sort(xs, 0, xs.length -1 ); } static void sort(int[] xs, int l, int r) { if (r >= l) return; int pivot = xs[l]; int a = l; int b = r; while (a <= b){ while (xs[a] <= pivot) a++; while (xs[b] > pivot) b--; if (a < b) swap(xs, a, b); } sort(xs, l, b); sort(xs, a, r); } static void swap(int[] arr, int i, int j) { int t = arr[i]; arr[i] = arr[j]; arr[j] = t; } } |
斯卡拉版本
1 2 3 4 5 6 7 8 9 10 11 12 |
用于比较实现的scala代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import java.util.Date import scala.testing.Benchmark class BenchSort(sortfn: (Array[Int]) => Unit, name:String) extends Benchmark { val ints = new Array[Int](100000); override def prefix = name override def setUp = { val ran = new java.util.Random(5); for (i <- 0 to ints.length - 1) ints(i) = ran.nextInt(); } override def run = sortfn(ints) } val benchImmut = new BenchSort( QuickSortS.sort ,"Immutable/Functional/Scala" ) val benchMut = new BenchSort( QuickSortJ.sort ,"Mutable/Imperative/Java " ) benchImmut.main( Array("5")) benchMut.main( Array("5")) |
结果
连续五次运行的时间(毫秒)
1 2 | Immutable/Functional/Scala 467 178 184 187 183 Mutable/Imperative/Java 51 14 12 12 12 |
由于这里有一些误解,我想澄清一些问题。
"就地"快速排序并不是真正到位的(而且根据定义,快速排序并不是就地的)。对于递归步骤,它需要以堆栈空间的形式提供额外的存储空间,在最佳情况下为O(log n),而在最坏情况下为O(n)。
实现对数组进行操作的QuickSort的功能变体会破坏此目的。数组绝不是不可变的。
Quicksort的"正确"功能实现使用不可变列表。它当然不在适当的位置,但是它有与过程的就地版本相同的最坏情况下的渐进运行时(o(n^2))和空间复杂性(o(n))。
平均而言,它的运行时间仍与就地变量(o(n logn))的运行时间相同。然而,它的空间复杂性仍然是O(N)。
功能性快速排序实现有两个明显的缺点。下面,让我们从haskell介绍中考虑一下haskell中的这个引用实现(我不知道scala…)。
1 2 3 4 | qsort [] = [] qsort (x:xs) = qsort lesser ++ [x] ++ qsort greater where lesser = (filter (< x) xs) greater = (filter (>= x) xs) |
第一个缺点是选择了轴元素,这是非常不灵活的。现代QuickSort实现的优势很大程度上依赖于对数据透视的智能选择(与Bentley等人的"工程排序函数"进行比较)。上述算法在这方面很差,这大大降低了平均性能。
其次,该算法使用了O(N)操作的列表连接(而不是列表构造)。这不会影响渐进复杂性,但它是一个可测量的因素。
第三个缺点是有点隐藏:与"就地"变量不同,这个实现不断地从堆中为列表的cons单元格请求内存,并可能将内存分散到各处。因此,该算法的缓存位置非常差。我不知道现代函数式编程语言中的智能分配器是否可以缓解这种情况——但是在现代机器上,缓存未命中已经成为一个主要的性能杀手。
结论是什么?与其他人不同的是,我不会说Quicksort本质上是必要的,这就是为什么它在FP环境中表现不佳的原因。恰恰相反,我认为Quicksort是一个完美的函数算法示例:它无缝地转换为不可变的环境,其渐进的运行时间和空间复杂性与过程实现相同,甚至其过程实现也使用递归。
但当约束到不可变域时,该算法的性能仍然较差。这是因为该算法具有独特的特性,可以从许多(有时是低级的)微调中获益,而这些微调只能在阵列上有效地执行。对quicksort的简单描述忽略了所有这些复杂性(在功能和程序变体中)。
在阅读了"工程排序函数"之后,我不再认为快速排序是一种优雅的算法。有效地实施,这是一个笨拙的混乱,一个工程师的作品,而不是艺术家的(不是贬低工程!这有它自己的审美观)。
但我也要指出,这一点对于流沙是特别的。不是所有的算法都能接受相同的低级调整。在不可变的环境中,许多算法和数据结构确实可以在不损失性能的情况下进行表示。
不变性甚至可以通过消除代价高昂的副本或跨线程同步的需求来降低性能成本。
所以,为了回答最初的问题,"不变性是昂贵的吗?"–在快速排序的特定情况下,有一个成本实际上是不可变的结果。但总的来说,没有。
作为函数式编程的基准,这有很多问题。亮点包括:
- 您使用的是原语,可能需要装箱/取消装箱。您不是在测试包装基元对象的开销,而是在测试不可变性。
- 您已经选择了一种算法,其中就地操作异常有效(并且可以证明是如此)。如果您想证明存在可变实现时速度更快的算法,那么这是一个不错的选择;否则,这可能会产生误导。
- 您使用的定时功能不正确。使用
System.nanoTime 。 - 对于您来说,这个基准太短了,以至于您无法确信JIT编译不会成为所测量时间的重要组成部分。
- 数组没有以有效的方式拆分。
- 数组是可变的,所以将它们与fp一起使用无论如何都是一个奇怪的比较。
所以,这个比较是一个很好的例子,您必须详细理解您的语言(和算法),才能编写高性能代码。但这不是一个很好的比较FP与非FP。如果你想这样,在计算机语言基准游戏中查看Haskell vs C++。带回家的信息是,惩罚通常不超过2或3倍左右,但这确实取决于。(没有保证哈斯克尔人也写了最快的算法,但至少他们中的一些人可能已经尝试过了!另外,一些haskell调用了C库……)
现在,假设您确实想要一个更合理的QuickSort基准,认识到这可能是FP与可变算法的最坏情况之一,并忽略数据结构问题(即,假设我们可以拥有一个不可变数组):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | object QSortExample { // Imperative mutable quicksort def swap(xs: Array[String])(a: Int, b: Int) { val t = xs(a); xs(a) = xs(b); xs(b) = t } def muQSort(xs: Array[String])(l: Int = 0, r: Int = xs.length-1) { val pivot = xs((l+r)/2) var a = l var b = r while (a <= b) { while (xs(a) < pivot) a += 1 while (xs(b) > pivot) b -= 1 if (a <= b) { swap(xs)(a,b) a += 1 b -= 1 } } if (l<b) muQSort(xs)(l, b) if (b<r) muQSort(xs)(a, r) } // Functional quicksort def fpSort(xs: Array[String]): Array[String] = { if (xs.length <= 1) xs else { val pivot = xs(xs.length/2) val (small,big) = xs.partition(_ < pivot) if (small.length == 0) { val (bigger,same) = big.partition(_ > pivot) same ++ fpSort(bigger) } else fpSort(small) ++ fpSort(big) } } // Utility function to repeat something n times def repeat[A](n: Int, f: => A): A = { if (n <= 1) f else { f; repeat(n-1,f) } } // This runs the benchmark def bench(n: Int, xs: Array[String], silent: Boolean = false) { // Utility to report how long something took def ptime[A](f: => A) = { val t0 = System.nanoTime val ans = f if (!silent) printf("elapsed: %.3f sec ",(System.nanoTime-t0)*1e-9) ans } if (!silent) print("Scala builtin") ptime { repeat(n, { val ys = xs.clone ys.sorted }) } if (!silent) print("Mutable") ptime { repeat(n, { val ys = xs.clone muQSort(ys)() ys }) } if (!silent) print("Immutable") ptime { repeat(n, { fpSort(xs) }) } } def main(args: Array[String]) { val letters = (1 to 500000).map(_ => scala.util.Random.nextPrintableChar) val unsorted = letters.grouped(5).map(_.mkString).toList.toArray repeat(3,bench(1,unsorted,silent=true)) // Warmup repeat(3,bench(10,unsorted)) // Actual benchmark } } |
请注意对功能性Quicksort的修改,以便它在可能的情况下只浏览一次数据,并与内置排序进行比较。当我们运行它时,会得到如下信息:
1 2 3 4 5 6 7 8 9 | Scala builtin elapsed: 0.349 sec Mutable elapsed: 0.445 sec Immutable elapsed: 1.373 sec Scala builtin elapsed: 0.343 sec Mutable elapsed: 0.441 sec Immutable elapsed: 1.374 sec Scala builtin elapsed: 0.343 sec Mutable elapsed: 0.442 sec Immutable elapsed: 1.383 sec |
因此,除了学习尝试编写自己的类型是一个坏主意之外,我们还发现,如果稍微仔细地实现一个不可变的快速排序,将受到大约3倍的惩罚。(您也可以编写一个返回三个数组的三等分方法:小于、等于和大于轴的数组。这可能会加快速度。)
我不认为scala版本实际上是tail递归的,因为您使用的是
而且,仅仅因为这是惯用的scala代码,这并不意味着这是最好的方法。
最好的方法是使用scala的内置排序函数之一。这样你就得到了不可变的保证,并且知道你有一个快速的算法。
参见堆栈溢出问题,如何在scala中对数组排序?举个例子。
不变并不昂贵。如果您测量一个程序必须执行的任务的一小部分,并根据启动的可变性(例如测量快速排序)选择一个解决方案,那么它肯定会很昂贵。
简单地说,在使用纯函数语言时,不会快速排序。
让我们从另一个角度来考虑这个问题。让我们考虑这两个功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // Version using mutable data structures def tailFrom[T : ClassManifest](arr: Array[T], p: T => Boolean): Array[T] = { def posIndex(i: Int): Int = { if (i < arr.length) { if (p(arr(i))) i else posIndex(i + 1) } else { -1 } } var index = posIndex(0) if (index < 0) Array.empty else { var result = new Array[T](arr.length - index) Array.copy(arr, index, result, 0, arr.length - index) result } } // Immutable data structure: def tailFrom[T](list: List[T], p: T => Boolean): List[T] = { def recurse(sublist: List[T]): List[T] = { if (sublist.isEmpty) sublist else if (p(sublist.head)) sublist else recurse(sublist.tail) } recurse(list) } |
对其进行基准测试,您会发现使用可变数据结构的代码的性能要差得多,因为它需要复制数组,而不可变的代码不需要关心这个问题。
当您使用不可变的数据结构编程时,您可以构造代码以充分利用其优势。它不仅仅是数据类型,甚至是单个算法。程序将以不同的方式设计。
这就是为什么基准测试通常毫无意义。您要么选择一种或另一种风格自然的算法,并且这种风格会获胜,要么对整个应用程序进行基准测试,这通常是不切实际的。
斯卡拉不变性的代价
这是一个几乎和Java一样快的版本。;)
1 2 3 4 5 6 7 8 |
此版本制作数组的副本,使用Java版本对其进行排序并返回副本。scala不强制您在内部使用不可变结构。
所以scala的好处是您可以根据自己的需要利用可变性和不可变性。缺点是,如果你做错了,你就不会真正得到不变的好处。
众所周知,在适当的地方进行快速排序会更快,所以这是一个不公平的比较!
这么说……Array.concat?如果没有其他选择,那么当您尝试在函数算法中使用集合类型时,您将展示为命令式编程优化的集合类型是多么的缓慢;几乎任何其他选择都会更快!
在比较这两种方法时,需要考虑的另一个非常重要的问题是:"这种扩展到多个节点/核心的程度如何?"
很可能,如果你在寻找一个不变的快速排序,那么你这样做是因为你实际上想要一个平行的快速排序。维基百科对此主题有一些引用:http://en.wikipedia.org/wiki/quicksort并行化
scala版本可以在函数再次出现之前进行分叉,如果您有足够的可用内核,它可以很快地对包含数十亿条条目的列表进行排序。
现在,我的系统中的GPU有128个内核可供我使用,如果我能在上面运行scala代码的话,这是在比当前一代晚两年的简单桌面系统上。
我想知道,与单线程命令式方法相比,这种方法会有什么不同…
也许更重要的问题是:
"考虑到单个内核的速度不会更快,同步/锁定对于并行化来说是一个真正的挑战,易变性是否昂贵?"
如果您只是简单地将命令式算法和数据结构重写为函数式语言,那么它确实是昂贵和无用的。为了让事情变得更精彩,您应该只在函数式编程中使用可用的特性:数据结构持久性、延迟评估等。
排序一个数组就像是宇宙中最必要的任务。许多优雅的"不变"策略/实现在"排序数组"微基准上失败并不奇怪。但这并不意味着不变性"总体上"是昂贵的。在许多任务中,不可变的实现将执行与可变的实现相似的任务,但数组排序通常不是其中之一。
有人说OO编程使用抽象来隐藏复杂性,而函数编程使用不变来消除复杂性。在scala的混合世界中,我们可以使用oo来隐藏命令式代码,使应用程序代码不再明智。实际上,集合库使用了大量的命令式代码,但这并不意味着我们不应该使用它们。正如其他人所说,谨慎使用,你真的能在这里得到最好的两个世界。