避免多个列表遍历的好处

时间:2012-12-03 15:56:28

标签: haskell functional-programming ml

我在函数式语言中看到很多关于处理列表和构造函数的函数,这些函数在接收到一些额外的值(通常在生成函数时不存在)之后对其元素执行某些操作,例如:

在所有这些例子中,作者通常都会注意到只遍历原始列表一次的好处。但是我不能让自己不要“确定,而不是遍历N个元素的列表,而是遍历一系列N个评估,那又怎么样?”。我知道必须有一些好处,有人可以解释一下吗?


编辑:感谢两者的回答。不幸的是,这不是我想知道的。我会试着澄清我的问题,所以它并没有与(更常见的)关于创建中间列表(我已经在不同的地方读过)相混淆。还要感谢我纠正我的帖子格式。

我对构建要应用于列表的函数的情况感兴趣,在该列表中,您还没有必要的值来评估结果(无论是否为列表)。然后,您无法避免生成对每个列表元素的引用(即使列表结构不再被引用)。并且您拥有与以前相同的内存访问权限,但您不必解构列表(模式匹配)。

例如,请参阅上述ML书中的“分段”章节。我在ML和Racket中尝试过它,更具体地说是“追加”的阶段版本,它遍历第一个列表并返回一个函数,在尾部插入第二个列表,而不会多次遍历第一个列表。令我惊讶的是,即使考虑到它仍然必须复制列表结构,因为最后一个指针在每种情况下都不同,所以它要快得多。

以下是map的变体,应用于列表后,更改函数时应该更快。由于Haskell不严格,我将不得不强制listMap [1..100000] cachedList中的listMap = foldr comb (const []) where comb x rest = \f -> f x : rest f cachedList = listMap [1..100000] doubles = cachedList (2*) squares = cachedList (\x -> x*x) -- print doubles and squares -- ... 的评估(或者可能不是,因为在第一次应用之后它应该仍然在内存中)。

comb x rest f = ...

我知道在Haskell中使用comb x rest = \f -> ... vs {{1}}并没有什么区别(如果我错了请纠正我),但我选择这个版本来强调这个想法。

更新:经过一些简单的测试后,我发现Haskell的执行时间没有任何差别。那么问题只是关于严格的语言,比如Scheme(至少是我测试它的Racket实现)和ML。

4 个答案:

答案 0 :(得分:27)

在循环体中执行一些额外的算术指令比执行一些额外的内存提取更便宜,基本上。

遍历意味着要进行大量内存访问,所以做得越少越好。遍历的融合减少了内存流量,并增加了直线计算负载,因此您可以获得更好的性能。

具体地说,考虑这个程序来计算列表中的一些数学:

go :: [Int] -> [Int]
go = map (+2) . map (^3)

显然,我们通过列表的两次遍历来设计它。在第一遍历和第二遍历之间,结果存储在中间数据结构中。但是,它是一个惰性结构,因此只需要O(1)内存。

现在,Haskell编译器立即将两个循环融合到:

go = map ((+2) . (^3))

为什么?毕竟,两者都是O(n)复杂性,对吧? 区别在于常数因素。

考虑这种抽象:对于第一个管道的每个步骤,我们做:

  i <- read memory          -- cost M
  j = i ^ 3                 -- cost A
  write memory j            -- cost M
  k <- read memory          -- cost M
  l = k + 2                 -- cost A
  write memory l            -- cost M

所以我们支付4次内存访问和2次算术运算。

对于融合结果,我们有:

  i <- read memory          -- cost M
  j = (i ^ 3) + 2           -- cost 2A
  write memory j            -- cost M

其中AM是在ALU和内存访问上进行数学运算的常数因素。

还有其他常数因素(两个循环分支)而不是一个。

因此,除非内存访问是免费的(远远不是这样),否则第二个版本总是更快。

请注意,对不可变序列进行操作的编译器可以实现数组融合,这是为您执行此操作的转换。 GHC就是这样一个编译器。

答案 1 :(得分:16)

还有另一个非常重要的原因。如果只遍历一次列表,并且没有其它引用,GC可以在遍历时释放列表元素声明的内存。而且,如果列表是懒惰生成的,那么你总是只有一个恒定的内存消耗。例如

import Data.List

main = do
    let xs = [1..10000000]
        sum = foldl' (+) 0 xs
        len = foldl' (\_ -> (+ 1)) 0 xs
    print (sum / len)

计算sum,但需要保留对xs的引用,并且无法释放它占用的内存,因为稍后需要计算len。 (反之亦然。)因此程序占用大量内存,xs所需的内存越大。

但是,如果我们只遍历列表一次,它会被懒惰地创建,并且元素可以立即成为GC,所以无论列表有多大,程序只需要O(1)个内存。

{-# LANGUAGE BangPatterns #-}
import Data.List

main = do
    let xs = [1..10000000]
        (sum, len) = foldl' (\(!s,!l) x -> (s + x, l + 1)) (0, 0) xs
    print (sum / len)

答案 2 :(得分:3)

提前抱歉,这是一个健谈的答案。

这可能很明显,但如果我们谈论的是性能,你应该总是通过测量验证假设。

几年前,我在考虑GHC(STG机器)的操作语义。我问自己同样的问题 - 当然着名的“一遍遍”算法并不是那么好吗?它看起来只是表面上的一次遍历,但是在引擎盖下你也有这种thunks链结构,它通常与原始列表非常相似。

我写了一些着名的RepMin问题的版本(严格不同) - 给一个填充数字的树,生成相同形状的树,但用最少的所有数字替换每个数字。如果我的记忆是正确的(记住 - 总是自己验证!),天真的双遍历算法比各种聪明的单遍历算法执行得快得多。

我也和Simon Marlow分享了我的观察结果(在那段时间我们都是FP暑期学校),他说他们在GHC中使用这种方法。但不是像你想象的那样提高性能。相反,他说,对于一个大的AST(比如Haskell的那个)写下所有构造函数需要很多空间(就代码行而言),所以他们只需通过写一个(句法)遍历来减少代码量

我个人避免这个伎俩,因为如果你犯了一个错误,你会得到一个循环,这是一个非常不愉快的调试。

答案 3 :(得分:2)

所以问题的答案是部分编译。提前完成,它使得不需要遍历列表来获取单个元素 - 所有引用都预先找到并存储在预编译函数中。

至于你是否也需要遍历该功能,在解释语言中也是如此。但编译消除了这个问题。

在存在懒惰的情况下,这种编码技巧可能导致相反的结果。有完整的方程,例如Haskell GHC编译器能够执行各种优化,从根本上完全消除列表,并将代码转换为等效的循环。当我们用例如编译代码时会发生这种情况-O2切换。

写出偏微分方程可能会阻止这种编译器优化并强制实际创建函数 - 结果代码的急剧减速。我尝试了你的cachedList代码,看到0.01秒的执行时间变成0.20秒(现在不记得我做过的确切测试)。