【问题标题】:Haskell garbage collectorHaskell 垃圾收集器
【发布时间】:2014-09-27 14:42:41
【问题描述】:

我有一个简单的玩具示例,它似乎与垃圾收集器不同意可以回收哪些数据结构(也称为内存泄漏)。我并不是想提出这个算法的内存效率更高的版本(这里有一组更好的算法:Haskell Wiki - Prime numbers,而是解释为什么垃圾收集器没有识别旧的、超出范围和未使用的部分列表以回收该内存。

代码在这里:

import Data.List (foldl')

erat' :: (Integer, Bool) -> [(Integer,Integer)] -> [(Integer,Integer)]
erat' (c,b) ((x,y):xs)
    | c < x = (x,y) : erat' (c,b) xs
    | c == x = (x+y,y) : erat' (c,True) xs
    | c > x = (x+y,y) : erat' (c,b) xs
erat' (c,b) []
    | b = []
    | otherwise = [(c,c)]

erat :: [Integer] -> [(Integer,Integer)]
erat = foldl' (\a c -> erat' (c,False) a) []

primes :: Integer -> [Integer]
primes n = map snd $ erat [2..n]

本质上,使用正整数调用素数将返回一个包含该数之前的所有素数的列表。素数对及其高水位标记倍数的列表被传递给 erat',以及一对包括候选和布尔值(素数为假,非素数为真)。每个对 erat' 的非递归调用都会传递一个新列表,我希望输出最多包含从列表开头到第一次更改点的某些共享单元。

一旦列表中的已修改单元格传递给 erat' 超出范围,内存应该被标记为恢复,但是当您尝试调用具有足够大数量(例如 1,000,000)的素数时可以看到),内存利用率会迅速飙升至数十 GB。

现在,问题是:为什么会发生这种情况?分代垃圾收集器不应该检测取消引用的列表单元来回收它们吗?而且,它是否应该很容易检测到它们没有引用,因为:

a) 没有任何东西可以从比它自己更早的数据结构中引用; b) 不能有更新的引用,因为这些单元格/片段甚至不再是可引用数据结构的一部分,因为它超出了范围?

当然,可变数据结构可以解决这个问题,但我觉得在这种情况下诉诸可变性会使 Haskell 的一些理论原则落空。

感谢评论的人(尤其是 Carl),我稍微修改了算法以增加严格性(以及开始穿过新素数的平方的优化,因为低倍数也会被低素数的倍数穿过)。

这是新版本:

import Data.List (foldl')

erat' :: (Integer, Bool) -> [(Integer,Integer)] -> [(Integer,Integer)]
erat' (c,b) ((x,y):xs)
    | c < x = x `seq` (x,y) : erat' (c,b) xs
    | c == x = x `seq` (x+y,y) : erat' (c,True) xs
    | c > x = x `seq` (x+y,y) : erat' (c,b) xs
erat' (c,b) []
    | b = []
    | otherwise = [(c*c,c)] -- lower multiples would be covered by multiples of lower primes

erat :: [Integer] -> [(Integer,Integer)]
erat = foldl' (\a c -> erat' (c,False) a) []

primes :: Integer -> [Integer]
primes n = map snd $ erat [2..n]

内存消耗似乎仍然相当大。此算法是否有任何其他更改可以帮助降低总内存利用率?

由于 Will 指出我没有提供完整的统计数据,因此这些是上面列出的更新版本的素数运行的数字,参数为 100000:

在应用 Will 提出的更改后,内存使用量现在大幅下降。例如,再次查看 100000 的素数运行:

最后,这是合并提议的更改后的最终代码:

import Data.List (foldl')

erat'' :: (Integer, Bool) -> [(Integer,Integer)] -> [(Integer,Integer)]
erat'' (c,b) ((x,y):xs)
    | c < x  = (x,  y) : if x==y*y then (if b then xs 
                                              else xs++[(c*c,c)])
                                   else erat'' (c,b)    xs 
    | c == x = (x+y,y) : if x==y*y then            xs 
                                   else erat'' (c,True) xs 
    | c > x  = (x+y,y) : erat'' (c,b)    xs 
erat'' (c,True)  [] = []
erat'' (c,False) [] = [(c*c,c)]


primes'' :: Integer -> [Integer]
primes'' n = map snd $ foldl' (\a c -> (if null a then 0 else 
        case last a of (x,y) -> y) `seq` erat'' (c,False) a) [] [2..n]

最后是跑 1,000,000 来感受一下这个新版本的性能:

【问题讨论】:

  • 如果 Haskell 是一种严格的语言,你的分析是正确的,但事实并非如此。
  • 我尝试使用 seq 和 deepseq 更改为严格,结果相似。我不认为非严格在这里起作用,但我可能错了。您愿意详细说明吗?
  • 乍一看,这似乎对foldl' 的使用非常糟糕。你试过foldr吗?
  • 我当然可以试试 foldr。理想情况下,为了清楚起见,我更愿意从左侧解析列表,这是我最初使用显式递归所做的,然后为了简洁起见将其转换为 foldl。
  • Ghci,最初。但是后来用 ghc 编译了可执行文件并从命令行运行它,因为我想把 ghci 从图片中删除。我使用 -O2 作为编译器优化标志。

标签: algorithm haskell garbage-collection primes sieve-of-eratosthenes


【解决方案1】:

假设 a) 在存在惰性的情况下是错误的。事实上,您的代码几乎完全由生成旧 cons 单元指向的 cons 单元组成。 erat' 使用一个列表元素,然后生成一个指向元组的(:) 构造函数和一个未评估 thunk,它将执行对erat' 的递归调用。只有在稍后评估该 thunk 时,(:) 列表构造函数才会真正指向它的尾部作为数据结构。所以,是的,您在erat' 中分配的几乎每个(:) 实际上都指向了时间。 (唯一的例外是最后一个 - [foo] 在分配 (:) 构造函数时将指向预先存在的 [] 构造函数。)

假设 b) 在存在懒惰的情况下是无稽之谈。范围决定了 Haskell 中的可见性,而不是生命周期。生命周期取决于评估和可达性。

所以在运行时发生的是你在erat 中建立了erat' 调用的管道。他们每个人都保留了已评估的尽可能多的输入,慢慢地消耗它。有趣的部分是您的代码不会提前评估任何内容 - 看起来它实际上应该很好地流式传输 - 除了管道太深的事实。创建的管道大约是n 阶段 - 这是(低效!)试验部门,而不是 Eratosthenes 的筛子。您应该只向管道添加质数,而不是每个数字。

【讨论】:

  • 我会尝试再次添加严格性,看看它是否表现得更好(以前似乎根本没有改善)。关于此为审判师的评论,您的评价是错误的。数字仅在 erat' 的基本情况下添加,并且仅当该对的第二个元素为假(素数)时。该列表仅包含质数及其迄今为止计算的最大倍数,这是标准婴儿床所需要的。
  • 添加严格性实际上会使情况变得更糟,因为它会立即将大量内容强制存储到内存中。而这绝对是审判师。例如,如果是筛子,您将永远不会从 foldl' 中的 erat 调用 erat',例如 c == 4。
  • erat' 管理婴儿床,因此需要为每个号码调用它。但是,对于 c==4,erat' 将有一个带有 [(4,2),(3,3)] 的列表。在 erat' 中,4 将被标记为非素数(将 Bool 设置为 True),列表将更改为 [(6,2),(6,3)] (跨越 2 和 3 的下一个倍数) ,但 4(正确)永远不会添加到婴儿床中。如果 c==5,列表将变成 [(6,2),(6,3),(5,5)]。缺少从新素数的平方开始的优化(因为较低的倍数已经与较低的素数的倍数交叉),您是否建议婴儿床应该以不同的方式工作?
  • 事实上这埃拉托色尼的筛子,因为它通过加法枚举素数的倍数。而且它没有添加非素数,只是它最终决定不这样做,重新扫描并重新创建了整个“婴儿床”(?)列表。
  • 折叠[2..n] 是潜在的性能问题。仅对素数进行折叠,逻辑仍然有效,但内存使用量将大幅减少。
【解决方案2】:

重大更新:你应该使用

map snd $ foldl' (\a c -> (if null a then 0 else 
        case last a of (x,y) -> y) `seq` erat' (c,False) a) [] [2..n]

在每次迭代时完全强制列表。它将consume less memory and run faster

上面的原因是foldl'只强制累加器为弱头正常形式,即使使用last a也是不够的,因为它会被强制为一对(_,_),而不强制其成分。

但是,当您的 erat' 函数发生更改以便它尽快停止扫描素数及其倍数的临时列表并尽可能共享其尾部(如下所述)时,即使没有强制,它也会更快,甚至如果使用更多内存。


您的(更新的)代码,为了便于阅读而稍作编辑:

g :: (Integer, Bool) -> [(Integer,Integer)] -> [(Integer,Integer)]
g (c,b) ((x,y):xs)
    | c < x  = (x,  y) : g (c,b)    xs  -- `c < x` forces the x already, 
    | c == x = (x+y,y) : g (c,True) xs  --              no need for `seq`
    | c > x  = (x+y,y) : g (c,b)    xs
g (c,True)  [] = []
g (c,False) [] = [(c*c,c)]

primes :: Integer -> [Integer]
primes n = map snd $ foldl' (\a c -> g (c,False) a) [] [2..n]

所以,您的primes n 实际上有点像反向[2..n] 列表中的右折叠。为flip $ foldl' (\a c -&gt; g (c,False) a)h,是

= map snd $ h [2..n] $ []
= map snd $ h [3..n] $ [(2*2,2)]
= map snd $ h [4..n] $ (4,2)  :(g (3,False) [])
= map snd $ h [5..n] $ (4+2,2):(g (4,True ) $ g (3,False) [])
= map snd $ h [6..n] $ (6,2)  :(g (5,False) $ g (4,True ) $ g (3,False) [])
....

foldl' 的严格性在此影响有限,因为累加器仅被强制为弱头范式。

(\a c -&gt; last a `seq` g (c,False) a) 折叠会得到我们

= map snd $ ... $ g (3,False) [(2*2,2)]
= map snd $ ... $ g (4,False) [(4,2),(3*3,3)]
= map snd $ ... $ g (5,False) [(4+2,2),(9,3)]
= map snd $ ... $ g (6,False) [(6,2),(9,3),(5*5,5)]
= map snd $ ... $ g (7,False) [(6+2,2),(9,3),(25,5)]
= map snd $ ... $ g (8,False) [(8,2),(9,3),(25,5),(7*7,7)]
= map snd $ ... $ g (9,False) [(8+2,2),(9,3),(25,5),(49,7)]
= map snd $ ... $ g (10,False) [(10,2),(9+3,3),(25,5),(49,7)]
= map snd $ ... $ g (11,False) [(10+2,2),(12,3),(25,5),(49,7)]
....
= map snd $ ... $ g (49,False) 
           [(48+2,2),(48+3,3),(50,5),(49,7),(121,11)...(2209,47)]
....

但是无论如何,所有这些更改都会被最终的print 推送到列表中,所以这里的懒惰不是直接的问题(它会导致更大输入的堆栈溢出,但这里是次要的)。问题是您的erat'(上面重命名为g)最终会不必要地通过整个列表推送每个条目,每个候选编号重新创建整个列表。这是一种非常繁重的内存使用模式。

它应该尽早停止,并尽可能共享列表的尾部:

g :: (Integer, Bool) -> [(Integer,Integer)] -> [(Integer,Integer)]
g (c,b) ((x,y):xs)
    | c < x  = (x,  y) : if x==y*y then (if b then xs 
                                              else xs++[(c*c,c)])
                                   else g (c,b)    xs 
    | c == x = (x+y,y) : if x==y*y then            xs 
                                   else g (c,True) xs 
    | c > x  = (x+y,y) :                g (c,b)    xs 
g (c,True)  [] = []
g (c,False) [] = [(c*c,c)]

使用 -O2 编译并独立运行,it runs~ N1.9 下与原始函数的 ~ N2.4..2.8..and rising 相比,产生高达 N 的素数em>。

(当然,一个“正常”的 Eratosthenes 筛应该以大约 ~ N1.1 运行,理想情况下,它的理论时间复杂度为 N log (log N )).

【讨论】:

  • 我将更改 foldl' 并按照您的建议从 erat 中删除 seq ,这是有充分理由的。然而,在erat'中提前退出列表扫描会产生其他后果。每次通过列表中的下一个数字都会更新列表,穿过下一轮高于候选者的倍数。提前退出将使右侧的倍数未交叉,因此将无法正确评估下一个数字。您是否将您的版本与我的版本进行比较以查看是否得到相同的结果?您的建议可以通过优先级列表来完成,假设是多个排序,但不是这个列表。
  • @fgv 不,不,我的编辑保持语义。是的,当然,同样的结果。你确实有排序,不是在倍数上,而是在素数上(不是第一个,而是一对的第二个字段)。
  • 顺便说一句,我只在 GHCi 中对其进行了测试,它给了我 100,000 的堆栈溢出,我不得不更改折叠函数以强制最后一个元素克服它。我怀疑它也可能改善记忆状况。
  • 你是对的:你的版本仍然保留语义,并以更好的内存配置文件运行。你介意发布关于强制最后一个元素的代码吗?
  • (抱歉,已离线)它位于答案的顶部:(if null a then 0 else case last a of (x,y) -&gt; y) `seq` ... 位。
猜你喜欢
  • 2012-04-14
  • 2018-12-30
  • 1970-01-01
  • 2011-11-07
  • 2013-04-01
  • 2012-06-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多