splitAt
函数可以实现如下(https://wiki.haskell.org/Lazy_pattern_match):
import Prelude hiding (splitAt)
splitAt :: Int -> [a] -> ([a], [a])
splitAt n xs =
if n<=0
then ([], xs)
else
case xs of
[] -> ([], [])
y:ys ->
case splitAt (n-1) ys of
~(prefix, suffix) -> (y : prefix, suffix) -- Here using lazy pattern match
main :: IO ()
main = do
let r = splitAt 1000000 $ repeat 'a'
print $ length $ fst r
使用严格模式匹配可以大大降低速度。
time ./lazy -- 0.04s user 0.00s system 90% cpu 0.047 total
time ./strict -- 0.12s user 0.02s system 96% cpu 0.147 total
我无法理解。根据文章,严格版本可能需要更多内存和所有递归调用来检查模式是否适合。但我认为懒惰版本还需要所有递归调用,并且需要内存来包含递归函数调用的结果。是什么造成了这些差异?
答案 0 :(得分:10)
存在许多差异。
让我们看一下第11行有和没有~
的变体之间的操作差异。
GHC中的评估Haskell由模式匹配驱动。当模式在case表达式或函数定义的LHS中匹配时,它需要评估模式中的构造函数。 (let和where中的模式被视为惰性模式匹配。)这意味着评估splitAt 1000000 (repeat 'a')
取决于匹配(,)
的递归调用产生的splitAt 999999 ...
构造函数,依此类推,一直到splitAt 0 ...
的最后一次调用。这需要堆栈空间来评估。事实上,相当多。堆栈必须多次生长才能避免崩溃。
此外,在"aaaaa..."
开始处理它之前,整个结果字符串length
都是在堆上构建的。 (由于repeat
中的优化,结果的第二个元素实际上是一个循环链表,它从不在整个递归计算中分配任何新的东西。)
当模式匹配变得懒惰时,事情会发生变化。 splitAt 1000000 (repeat 'a')
的返回值被评估为('a':_thunk1, _thunk2)
,而不会对splitAt
进行递归调用。这是一种被称为保护的核心运动的模式。进一步的评估隐藏在(,)
和(:)
之类的数据构造函数之后,因此只有在另一个案例表达式要求时才会执行。
对fst
的调用会抛弃_thunk2
,因此不会对其进行评估。对length
的调用首先使用第一个(:)
构造函数,抛出'a'
值,然后在_thunk1
上进行递归调用。此时,内存中没有任何内容仍然指向(:)
构造函数,因此垃圾收集器可以在下次运行时自由回收它。 ('a'
值是共享的,因此仍有指针,因此在此过程中既不会收集也不会分配。)
评估_thunk1
时会发生什么有点微妙。它对splitAt 999999 ...
进行递归调用。它导致('a':_thunk3, _thunk4)
。没有任何内容可以保留_thunk4
,因此无论何时都可以随意收集垃圾。 length
的评估如上所述。 (:)
构造函数不再保留在内存中,可以自由收集。
评估以这种方式进行,一次只能保持堆上的单个(:)
构造函数,并且根本不会烧掉任何堆栈空间。 GHC的垃圾收集器的运行时间取决于驻留集的大小。由于最多只有一个(:)
构造函数驻留,因此在这种情况下它真正。
我怀疑在这种情况下,这就是你所看到的速度差异。您应该尝试使用参数+RTS -s
运行这两个程序,并查看有关最大驻留大小和垃圾收集器运行时间的统计信息。
尽管如此,GHC可能真的聪明。我没有检查过,但我知道在某些情况下它可以根据build
函数在显式(:)
应用程序方面重写算法。如果要这样做,它将允许foldr / build fusion完全删除(:)
构造函数的分配。 (是的,length
是根据foldr
通过一些非常酷的效率技巧来定义的,主要是为了让foldr / build fusion能够工作。)
如果是这种情况,你会发现在懒惰情况下发生的分配更少 - 但严格的情况也同样糟糕。我不认为这可能发生在这里,但我不确定,因为我没有测试过。