【问题标题】:Benefit of avoiding multiple list traversals避免多次列表遍历的好处
【发布时间】:2023-03-07 08:52:01
【问题描述】:

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

在所有这些示例中,作者通常都提到只遍历原始列表一次的好处。但我不能不让自己思考“当然,不是遍历 N 个元素的列表,而是遍历 N 个评估链,那又怎样?”。我知道它一定有一些好处,有人可以解释一下吗?


编辑:感谢两位的回答。不幸的是,这不是我想知道的。我将尝试澄清我的问题,因此它不会与(更常见的)关于创建中间列表的问题(我已经在各个地方读到过)混淆。也感谢您更正我的帖子格式。

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

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

以下是 map 的变体,应用于列表后,更改功能时应该更快。由于 Haskell 并不严格,我将不得不在 cachedList 中强制评估 listMap [1..100000](或者可能不会,因为在第一个应用程序之后它应该仍然在内存中)。

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
-- ...

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

更新:经过一些简单的测试,我在 Haskell 中找不到任何执行时间的差异。那么问题只是关于严格的语言,例如 Scheme(至少是我测试过的 Racket 实现)和 ML。

【问题讨论】:

  • 您确定是listEq a b = foldr comb null b a 而不是listEq a b = foldr comb null a b?哪里来的?
  • 关于 staging - 我认为它允许部分预编译。这样的预编译函数体现了一些已经完成的工作,提前。特别是,列表的遍历只进行一次,所有对单个元素的引用都从列表中提取出来并存储在一个已编译的函数中,以备使用。不遍历编译函数,只遍历解释函数。
  • 关于“a b”与“b a”你是对的,它应该是相反的。实际上它们可以从两边删除,我保留它们的原因是因为 ghci 在没有它们的情况下会产生错误(使用“let listEQ = ...”时)。如果它们的顺序相反,我正在搜索的部分评估将不会发生。
  • “...不会发生”——这正是我建议的原因。不过,您忘记在上次编辑中更改它。 :)
  • 一个相关的 Haskell 链接:haskell.org/haskellwiki/Runtime_compilation,提到了here。虽然,在 Haskell 中,我们无法明确区分柯里化声明和实际登台(编译)。通常,如果声明了 named 实体,它将被编译为单独的内存驻留实体;但这是一个编译器的东西(又名实现细节)。

标签: haskell functional-programming ml


【解决方案1】:

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

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

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

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 来扩展已经明确和有效的答案,以提高清晰度。
【解决方案2】:

还有一个很重要的原因。如果你只遍历一个列表一次,并且没有其他引用,那么 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)

【讨论】:

  • 有趣的是,您可以使用 sparks 并行计算 sumlen,同时避免 O(n) 空间。
  • @DonStewart 好点。我想问,你能靠得住吗?我的意思是,你能确定一个火花不会因为某种原因落后于另一个火花(或者不会很快开始)并且差距最终会占用 O(n) 空间吗?
  • 我认为操作行为是不确定的,所以你不能保证并行进度。
【解决方案3】:

很抱歉,您需要一个闲聊式的回答。

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

几年前,我在考虑 GHC(STG 机器)的操作语义。我也问过自己同样的问题——著名的“单次遍历”算法肯定不是很好吗?它只是表面上看起来像一次遍历,但在底层你也有这个链式结构,通常与原始列表非常相似。

我为著名的 RepMin 问题写了几个版本(严格程度不同)——给定一棵充满数字的树,生成相同形状的树,但用所有数字中的最小值替换每个数字。如果我没记错的话(记住——总是自己验证东西!),朴素的两次遍历算法比各种聪明的一次遍历算法执行得快得多。

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

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

【讨论】:

    【解决方案4】:

    所以你的问题的答案是,部分编译。提前完成,这样就无需遍历列表来获取各个元素 - 所有引用都提前找到并存储在预编译函数中。

    至于您担心也需要遍历该函数,这在解释语言中是正确的。但是编译消除了这个问题。

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

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

    【讨论】:

    • 当您说“部分编译”时,我认为这不是在运行时完成的(与某些具有 JIT 的动态语言相反)。所以你的意思是它只在列表在编译时已知时才有效,或者它甚至可以优化重用在运行时已知的列表?
    • @user1871626 有些系统能够运行时编译,例如常见的 LISP。它是在那里明确完成的,例如有一个函数fun,你可以调用(compile 'fun)(比如在REPL提示符下)它会被编译,编译后的代码会链接到运行时。分期技术很适合那里。编译后,该函数体现了列表的预处理知识,并且应用(多次)这样编译的函数更有效,正如您链接到的书所解释的那样。
    猜你喜欢
    • 2017-05-30
    • 2017-10-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多